Compare commits

...

31 Commits

Author SHA1 Message Date
Dan Willis 44525da669 Sketch Project: a face of an imported mesh projects its flat patch (#13464) (#13482)
* Sketch Project: a face of an imported mesh projects its flat patch (#13464)

An imported mesh is one face with no edges, so Project on one of its
faces always failed with "projects to nothing on this sketch plane". A
pick on a mesh body's face now means the flat patch of triangles there,
traced as closed loops (project3d::flat_outline) and fitted like other
traced outlines. The link keeps the pick's point instead of the mesh's
one face name, so the sketch still rebuilds.

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

* Sketch Project: a mesh face's polygon keeps its straight sides on a slanted plane (#13464)

Any triangle's corners and any rectangle's lie on one circle, so on a
sketch plane at a slant the outline could be fitted as a circle seen at
a slant (an ellipse). A mesh outline is now taken for a circle only when
it has more than 12 corners on one circle, checked within the single
precision a mesh file keeps; otherwise its sides stay lines. Corners
where the outline runs straight on (a triangle meeting a side part way
along) are dropped. From CodeRabbit's review on #13482.

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

* Sketch Project: mesh outline tolerances follow single precision and the outline's size (#13464)

A corner part way along a side is dropped when it lies within single
precision of the line through its neighbours (1e-6 of the coordinates'
size), measured as its distance from that line, so a rounded corner at
the loop's start no longer splits a side in two. The circle check for a
mesh outline is never looser than 1e-4 of the outline's own size, so a
small outline far from the origin is not taken for a circle. From
CodeRabbit's second review on #13482.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:27:06 +00:00
Loohi 33c442f031 Sweep: input and output schemas (part of #2829) (#13411)
* claim: part of #2829, sweep schemas

* Sweep and Surface Sweep: input and output schemas (part of #2829)

solid.sweep and surface.sweep get typed contracts and FeatureResult as
their output schema, so warn mode reports and strict refuses a misspelt
or wrong-typed key (orientation, twist, scale, rails, rail_fit, a rail's
own keys). Strict keeps the handlers' messages; replies are unchanged.

The JSON-only readers for orientation and rail_fit are split out of
sweep_options and guide_rails, unchanged, so the contract types call
them. inspect_tests' unconverted example moves to solid.emboss.

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

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 18:59:45 +00:00
Kai Evans 8d47023049 Sketch: a dimension edit the solver gave up on is retried in steps and reaches the new sizes (#12149)
A value edit the one-jump solve fails or turns over is retried in 2, 4, 8,
then 16 equal steps, each a full solve of the whole sketch from the last
step's solution. Continuation follows one solution branch by the iteration
alone, sub-step to sub-step; that branch ends before the new values on the
linkage family, where a solve from the values a step before still reaches
them keeping every turn. The retry runs when the stored sketch is solved for
the old values, as `ValueEdit::starts_solved` decides per component, so a
sketch stored unsolved keeps its one-jump result.

Linkage family, 2,400 random edits: 1,540 refused before, 1,533 after, the 7
newly solved keeping every turn. 941 edits on 18 sketches from real designs:
34 refused before and after.

Tests: solidcraft-sketch 333 passed, 0 failed, 3 ignored.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-08 18:52:41 +00:00
github-actions[bot] 3c619790ce Regenerate generated files and fold pending files [skip ci] 2026-11-08 19:48:58 +00:00
Mek f4d9519bb9 Hole: a counterbore sized to the screw's head, from clearance and head (#12321) (#12322)
* claim: #12321

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

Fixes #13097

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

Part of #2829.

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Raised by CodeRabbit on #12628.

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

---------

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

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

* Schemas for Plot Motion Study and Motion Interference

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

Fixes #12852, part of #2829.

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

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

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

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

* Render export: type the reply before writing the file

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

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

---------

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

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

* Schemas for Sweep Joint (joint.sweep)

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

Fixes #12844, part of #2829.

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

---------

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

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

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

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

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

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

* Chamfer schemas: format

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-08 13:07:13 +00:00
529 changed files with 18667 additions and 9362 deletions
+33 -3
View File
@@ -505,9 +505,15 @@ jobs:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -528,7 +534,7 @@ jobs:
compression-level: 0
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
tests:
name: Native Linux tests
@@ -576,9 +582,15 @@ jobs:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -619,7 +631,7 @@ jobs:
cargo xtask known-failing --junit "$junit"
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -692,9 +704,15 @@ jobs:
key: native-gpu
save-if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && matrix.shard == 1 }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -715,7 +733,7 @@ jobs:
cargo xtask known-failing --junit "$junit"
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -848,9 +866,15 @@ jobs:
key: doc-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
# nextest does not run doc tests.
- name: Doc tests
env:
@@ -906,9 +930,15 @@ jobs:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the command line
run: cargo build -p solidcraft-cli --profile ci --locked
- name: Replay tests/oracle and compare with expected.json
+6
View File
@@ -85,9 +85,15 @@ jobs:
key: fillet-rig
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the CLI (release)
run: cargo build --release --locked -p solidcraft-cli
- name: Run the fillet cases against the known-failing list
+7 -1
View File
@@ -149,9 +149,15 @@ jobs:
key: kernel-rig
save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the CLI (release) and the rig's runner
run: |
cargo build --release --locked -p solidcraft-cli
@@ -174,7 +180,7 @@ jobs:
retention-days: 1
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
shard:
name: Kernel rig (${{ matrix.shard }})
+1 -1
View File
@@ -13,7 +13,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- New kernel cases go to solidcore: `cargo xtask lists`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
-1
View File
@@ -201,7 +201,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
Generated
+76 -3
View File
@@ -4075,7 +4075,6 @@ dependencies = [
"eframe",
"egui",
"embed-resource",
"getrandom 0.3.4",
"hidapi",
"keyring",
"libc",
@@ -4093,10 +4092,13 @@ dependencies = [
"serde",
"serde_json",
"solidcraft-3dx-client",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-theme",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"ureq",
"windows-sys 0.61.2",
"winit",
@@ -4123,6 +4125,29 @@ dependencies = [
"solidcraft-mcp",
]
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-control"
version = "0.40.0-rc.3"
dependencies = [
"getrandom 0.3.4",
"serde",
"serde_json",
]
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"image",
"serde_json",
"solidcraft-control",
]
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.3"
@@ -4141,6 +4166,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4173,6 +4199,8 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"solidcore-record",
"solidcraft-color",
"solidcraft-control",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
@@ -4207,6 +4235,7 @@ dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcraft-color",
"solidcraft-engine",
"solidcraft-geom",
"solidcraft-io",
@@ -4234,6 +4263,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4268,8 +4298,17 @@ version = "0.40.0-rc.3"
dependencies = [
"image",
"serde_json",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-mcp-server",
]
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
]
[[package]]
@@ -4293,6 +4332,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-geom",
"web-time",
"weezl",
@@ -4345,6 +4385,16 @@ dependencies = [
name = "solidcraft-testkit"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"serde",
"serde_json",
"solidcraft-color",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.3"
@@ -4363,20 +4413,41 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-control",
"solidcraft-control-egui",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-theme",
"solidcraft-ui-registry",
"solidcraft-ui-text",
"syn 2.0.119",
"sys-locale",
"tiny-skia",
"toml",
"ttf-parser",
"unicode-bidi",
"web-time",
"winreg",
"zip",
]
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde_json",
"sys-locale",
"unicode-bidi",
]
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.3"
@@ -4387,8 +4458,10 @@ dependencies = [
"log",
"percent-encoding",
"serde_json",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+7
View File
@@ -28,8 +28,15 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-color = { path = "crates/color" }
solidcraft-ui-text = { path = "crates/ui-text" }
solidcraft-theme = { path = "crates/theme" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-mcp-server = { path = "crates/mcp-server" }
solidcraft-control = { path = "crates/control" }
solidcraft-control-egui = { path = "crates/control-egui" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
+2 -1
View File
@@ -68,7 +68,8 @@ impl Control {
if !addr.ip().is_loopback() || addr.port() == 0 {
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
}
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
let token = solidcraft_engine::control_auth::APP
.client_token(addr.port(), token)
.map(|t| t.token)
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
if token.len() > 4096 {
+1 -1
View File
@@ -916,7 +916,7 @@ fn serve_mcp(
/// The port of the running desktop app: the one its discovery file names, if something listens
/// there (a file left by an app that crashed names a closed port).
fn discover_app() -> Option<u16> {
let port = solidcraft_engine::control_auth::read_current_port()?;
let port = solidcraft_engine::control_auth::APP.read_current_port()?;
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
}
+2
View File
@@ -22,7 +22,9 @@ profile = ["solidcraft-engine/profile"]
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_ui_egui::ControlRequest;
use solidcraft_control::ControlRequest;
use wasm_bindgen::{JsValue, closure::Closure};
const MAX_PENDING: usize = 256;
+4 -4
View File
@@ -48,15 +48,15 @@ impl Download {
}
if solidcraft_ui_egui::documents::any_dirty(app) {
let mut answer = None;
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
.required()
.show_body(ctx, |ui| {
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
ui.horizontal(|ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
answer = Some(true);
}
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
answer = Some(false);
}
});
+9 -8
View File
@@ -810,7 +810,7 @@ mod web {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -864,7 +864,7 @@ mod web {
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
@@ -1016,7 +1016,7 @@ mod web {
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
@@ -1058,7 +1058,7 @@ mod web {
false
}
};
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
@@ -1067,13 +1067,13 @@ mod web {
ctx.request_repaint();
}
let ready = self.script_font_ready();
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
self.apply_fonts(ctx);
}
}
@@ -1081,7 +1081,7 @@ mod web {
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
@@ -1149,7 +1149,7 @@ mod web {
UNSAVED.with(|u| u.set(self.asks_before_unload()));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
{
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
@@ -1225,6 +1225,7 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
solidcraft_ui_egui::i18n::install();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
let launch = crate::Launch::parse(&query());
+3 -1
View File
@@ -31,9 +31,12 @@ neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
solidcraft-gpu = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
solidcraft-theme = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
@@ -45,7 +48,6 @@ rfd = { workspace = true }
ron = "0.12"
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
pollster = { workspace = true }
getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -468,7 +468,7 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
let content = ui.ctx().content_rect();
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
ui.painter().rect_filled(above, 0.0, solidcraft_theme::Tokens::get().app_bar);
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
}
@@ -518,7 +518,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
// The groups come from the format registry, so a new format joins this dialog by itself.
let mut everything = io::formats::open_extensions(true);
everything.extend(["dxf", "svg"]);
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
@@ -945,6 +945,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
solidcraft_ui_egui::i18n::install();
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
@@ -1221,7 +1222,7 @@ fn main() -> eframe::Result {
mark("viewport pipelines");
// `--control`/the environment name a port; otherwise the preference (on by default)
// opens the default one (docs/control-protocol.md).
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
app = app.with_control(rx);
}
-1
View File
@@ -18,7 +18,6 @@ assets/
ui/source/ # Rust viewport glyphs, markers and navigation
branding/web/source/og.png # shared social preview card
branding/logo/ # print/web logo files; guide in branding/README.md
styles/ui/palettes.rs # light/dark defaults
styles/web/loading.css # web loading/canvas styles
appearances/library.rs # built-in visual presets
materials/library.rs # physical defaults and colours
+1 -1
View File
@@ -38,7 +38,7 @@ Other colours, all already used in the project:
| Colour | Hex | Where it comes from |
|---|---|---|
| Wordmark blue | `#15499e` | loading logo `--word` |
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
| Interface accent | `#2678da` | light theme `accent` token (`crates/theme/src/palettes.rs`) |
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
| Dark background | `#1f232b` | dark theme `file_background` token |
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
+1 -1
View File
@@ -81,7 +81,7 @@ for message in icon_palette.validation_errors(pal, names | set(al)):
err(d + '/palette.toml', message)
# the review page lies its icons on the real app surfaces: its colours must be the app's tokens
def app_tokens():
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read()
src = open(os.path.join(d, '..', '..', '..', '..', 'crates', 'theme', 'src', 'palettes.rs'), encoding='utf-8').read()
lo, hi = src.index('pub const fn light'), src.index('pub const fn dark')
light_src, dark_src = src[lo:hi], src[hi:]
def pick(block, name, dark):
@@ -1,4 +1,4 @@
/* App surfaces (keep in sync with assets/styles/ui/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
/* App surfaces (keep in sync with crates/theme/src/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
:root{--bg:#fafbfc;--fg:#262a32;--card:#f6f7f9;--line:#d0d4dc;--mut:#6e7480;--sz:48px}
body.dark{--bg:#2e3440;--fg:#dee2e8;--card:#272b34;--line:#3f4552;--mut:#949ba8}
body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)}
+2 -2
View File
@@ -110,7 +110,7 @@ pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
}
pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
brand_mark_styled(p, c, 20.0, crate::theme::Tokens::get().logo, true);
brand_mark_styled(p, c, 20.0, solidcraft_theme::Tokens::get().logo, true);
}
/// The same original mark as a single quiet silhouette, without face seams or outlines.
@@ -127,7 +127,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
};
// The faces are separated by gaps that show the application bar, not by white lines.
let [light, mid, dark] = colours;
let edge_col = crate::theme::Tokens::get().app_bar;
let edge_col = solidcraft_theme::Tokens::get().app_bar;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
+2 -2
View File
@@ -1,7 +1,7 @@
//! Constraint, assembly snap, hole placement and dimension marker artwork.
use crate::dialogs_assembly::SnapKind;
use crate::theme::Tokens;
use egui::{Color32, Pos2, Stroke, vec2};
use solidcraft_theme::Tokens;
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
@@ -49,7 +49,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
// What it snaps to (hovered), or which component it is (picked): a small label.
if let Some(tag) = tag {
let t = Tokens::get();
let font = egui::FontId::proportional(crate::theme::Tokens::get().font_caption);
let font = egui::FontId::proportional(solidcraft_theme::Tokens::get().font_caption);
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
let at = c + vec2(12.0, 10.0);
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
+2 -2
View File
@@ -1,8 +1,8 @@
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
use crate::theme::Tokens;
use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
use solidcore_record::Vec3;
use solidcraft_engine::geom::Ring;
use solidcraft_theme::Tokens;
pub(crate) struct Facet {
pub(crate) direction: Vec3,
pub(crate) points: Ring<Pos2>,
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &
// On a hovered (white) disc the letter takes the axis colour.
let letter = if hovered { col } else { t.on_accent };
let label = name.to_uppercase();
let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::theme::Tokens::get().font_compass * scale), letter);
let galley = painter.layout_no_wrap(label, crate::fonts::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let origin = at - galley.size() / 2.0;
let ppp = painter.ctx().pixels_per_point().max(0.5);
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
+3 -3
View File
@@ -2,16 +2,16 @@
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
pub(crate) fn agent() -> Color32 {
crate::theme::Tokens::get().icons.agent
solidcraft_theme::Tokens::get().icons.agent
}
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
let ic = crate::theme::Tokens::get().icons;
let ic = solidcraft_theme::Tokens::get().icons;
// An arrow pointer, tip at `p`.
let pts: Vec<Pos2> =
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::theme::Tokens::get().font_small), ic.highlight);
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(solidcraft_theme::Tokens::get().font_small), ic.highlight);
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
painter.rect_filled(r, 4.0, ic.agent);
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
+9 -8
View File
@@ -1250,10 +1250,10 @@
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
"cmd.panel.sash.reset": "إعادة ضبط فاصل اللوحة التي عليها التركيز",
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
"cmd.panel.toggle": "طي اللوحة التي عليها التركيز أو توسيعها",
"cmd.parameters_add": "إضافة معامل مستخدم",
"cmd.parameters_change": "تغيير المعاملات",
"cmd.parameters_comment": "التعليق على المعامل",
@@ -1510,7 +1510,7 @@
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "متصفح التصيير",
"cmd.view.render_browser": "مستعرض التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
"cmd.view.render_focus": "عرض تركيز التصيير",
"cmd.view.render_live": "إيقاف التصيير المباشر مؤقتًا أو استئنافه",
@@ -2218,6 +2218,7 @@
"material.editor.preserved_appearance": "المظهر المحفوظ",
"material.editor.public_reference": "مرجع عام",
"material.editor.reference": "مرجع",
"material.editor.remove": "إزالة",
"material.editor.save_or_discard": "احفظ مسودة المادة أو تجاهلها قبل تعيين مادة.",
"material.editor.saved": "حُفظت المادة. تعيينات الأجسام لم تتغير.",
"material.editor.scope": "الحفظ يخزّن هذه المادة دون تعيينها للأجسام. الخصائص والشروط الفارغة تبقى غير معروفة. كثافة البطاقة لا تغيّر الكثافة المعيّنة ولا الكتلة.",
@@ -2852,7 +2853,7 @@
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
"prefs.selecting": "التحديد",
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
"prefs.shared_panel_layout_help": "اعرض المستعرض والسجل والمساعد الحالية في لوحات قابلة للنقل. يسري التغيير عند تطبيق التفضيلات.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
@@ -3613,7 +3614,7 @@
"render.flare_streak": "خط ضوئي بأسلوب أنامورفي",
"render.flare_strength": "شدة وهج الإضاءات العالية",
"render.flare_threshold": "عتبة الوهج الخطية للمشهد",
"render.fold_browser": "طيّ المتصفح",
"render.fold_browser": "طيّ المستعرض",
"render.frame_aspect": "النسبة",
"render.frame_centre_mark": "علامة المركز",
"render.frame_copy_limit": "لا يوجد اسم متاح للقطة مكررة",
@@ -3843,7 +3844,7 @@
"render.light_gpu_punctual": "الأضواء النقطية والكشّافة متاحة مع تتبّع الأشعة على المعالج.",
"render.light_hard_shadow": "لهذا الضوء ظلال حادة؛ نعومة المصدر غير متاحة.",
"render.light_intensity": "الشدة",
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المتصفح.",
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المستعرض.",
"render.light_place_highlight": "وضع بالتمييز",
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منطقة العرض وهو غير مدعوم بعد.",
"render.light_position": "الموضع",
@@ -4456,7 +4457,7 @@
"render.transmission_strata_help": "القيمة 1 تحافظ على التدفق الأصلي. 2 أو 4 مسارات فرعية تقسّم أول اختيار انعكاس أو نفاذ في الزجاج إلى طبقات مع بقاء قانوني سنل والامتصاص دون تغيير. المزيد من المسارات يكلف عملًا أكثر؛ وهذا ليس إزالة تشويش ولا دعمًا للأوساط المتداخلة ولا وعدًا بتصيير أسرع.",
"render.tree_add_light": "إضافة ضوء",
"render.tree_add_shot": "إضافة لقطة",
"render.tree_browser": "متصفح التصيير",
"render.tree_browser": "مستعرض التصيير",
"render.tree_by_look": "حسب المظهر",
"render.tree_by_object": "حسب الكائن",
"render.tree_constant": "بيئة ثابتة",
@@ -5888,7 +5889,7 @@
"ui.project": "إسقاط",
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
"ui.project_references_construction_note": "تصبح الهندسة التي تنشئها أوامر «إسقاط» و«تقاطع» و«تضمين هندسة ثلاثية الأبعاد»، وحواف الوجه في رسم جديد على وجه، هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
"ui.properties": "الخصائص",
"ui.properties.copy_matrix": "نسخ المصفوفة",
"ui.properties.first_moments": "العزوم الأولى",
+1
View File
@@ -1395,6 +1395,7 @@
"material.editor.preserved_appearance": "Preserved appearance",
"material.editor.public_reference": "Public reference",
"material.editor.reference": "Reference",
"material.editor.remove": "Remove",
"material.editor.save_or_discard": "Save or discard the material draft before assigning a material.",
"material.editor.saved": "Material saved. Body assignments are unchanged.",
"material.editor.scope": "Save stores this material without assigning it to bodies. Blank properties and conditions remain unknown. Card density does not change assigned density or mass.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Sačuvani izgled",
"material.editor.public_reference": "Javna referenca",
"material.editor.reference": "Referenca",
"material.editor.remove": "Ukloni",
"material.editor.save_or_discard": "Sačuvajte ili odbacite nacrt materijala prije dodjele materijala.",
"material.editor.saved": "Materijal je sačuvan. Dodjele tijelima ostaju nepromijenjene.",
"material.editor.scope": "Sačuvaj sprema ovaj materijal bez dodjele tijelima. Prazna svojstva i uslovi ostaju nepoznati. Gustina s kartice ne mijenja dodijeljenu gustinu ni masu.",
+1
View File
@@ -2204,6 +2204,7 @@
"material.editor.preserved_appearance": "Aparença conservada",
"material.editor.public_reference": "Referència pública",
"material.editor.reference": "Referència",
"material.editor.remove": "Eliminar",
"material.editor.save_or_discard": "Deseu o descarteu l'esborrany de material abans d'assignar un material.",
"material.editor.saved": "Material desat. Les assignacions als cossos no han canviat.",
"material.editor.scope": "Desar emmagatzema aquest material sense assignar-lo a cap cos. Les propietats i condicions buides continuen sent desconegudes. La densitat de la fitxa no canvia la densitat assignada ni la massa.",
+1
View File
@@ -2204,6 +2204,7 @@
"material.editor.preserved_appearance": "Zachovaný vzhled",
"material.editor.public_reference": "Veřejná reference",
"material.editor.reference": "Reference",
"material.editor.remove": "Odebrat",
"material.editor.save_or_discard": "Před přiřazením materiálu uložte nebo zahoďte koncept materiálu.",
"material.editor.saved": "Materiál byl uložen. Přiřazení k tělesům se nezměnila.",
"material.editor.scope": "Uložením se materiál uloží bez přiřazení k tělesům. Prázdné vlastnosti a podmínky zůstávají neznámé. Hustota z karty nemění přiřazenou hustotu ani hmotnost.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Bevaret udseende",
"material.editor.public_reference": "Offentlig reference",
"material.editor.reference": "Reference",
"material.editor.remove": "Fjern",
"material.editor.save_or_discard": "Gem eller kassér materialeudkastet, før du tildeler et materiale.",
"material.editor.saved": "Materialet er gemt. Tildelinger til emner er uændrede.",
"material.editor.scope": "Gem gemmer materialet uden at tildele det til emner. Tomme egenskaber og betingelser forbliver ukendte. Kortets densitet ændrer ikke den tildelte densitet eller massen.",
+1
View File
@@ -2010,6 +2010,7 @@
"material.editor.preserved_appearance": "Beibehaltenes Erscheinungsbild",
"material.editor.public_reference": "Öffentliche Quelle",
"material.editor.reference": "Referenz",
"material.editor.remove": "Entfernen",
"material.editor.save_or_discard": "Speichern oder verwerfen Sie den Materialentwurf, bevor Sie ein Material zuweisen.",
"material.editor.saved": "Material gespeichert. Körperzuweisungen bleiben unverändert.",
"material.editor.scope": "Speichern legt dieses Material ab, ohne es Körpern zuzuweisen. Leere Eigenschaften und Bedingungen bleiben unbekannt. Die Dichte im Datenblatt ändert weder die zugewiesene Dichte noch die Masse.",
+1
View File
@@ -2218,6 +2218,7 @@
"material.editor.preserved_appearance": "Preserved appearance",
"material.editor.public_reference": "Public reference",
"material.editor.reference": "Reference",
"material.editor.remove": "Remove",
"material.editor.save_or_discard": "Save or discard the material draft before assigning a material.",
"material.editor.saved": "Material saved. Body assignments are unchanged.",
"material.editor.scope": "Save stores this material without assigning it to bodies. Blank properties and conditions remain unknown. Card density does not change assigned density or mass.",
+1
View File
@@ -2204,6 +2204,7 @@
"material.editor.preserved_appearance": "Apariencia conservada",
"material.editor.public_reference": "Referencia pública",
"material.editor.reference": "Referencia",
"material.editor.remove": "Eliminar",
"material.editor.save_or_discard": "Guarda o descarta el borrador antes de asignar un material.",
"material.editor.saved": "Material guardado. Las asignaciones a cuerpos no han cambiado.",
"material.editor.scope": "Guardar almacena este material sin asignarlo a cuerpos. Las propiedades y condiciones vacías siguen siendo desconocidas. La densidad de la ficha no cambia la densidad asignada ni la masa.",
+625 -6
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1899,6 +1899,7 @@
"material.editor.preserved_appearance": "Apparence conservée",
"material.editor.public_reference": "Référence publique",
"material.editor.reference": "Référence",
"material.editor.remove": "Retirer",
"material.editor.save_or_discard": "Enregistrez ou abandonnez le brouillon de matériau avant d'affecter un matériau.",
"material.editor.saved": "Matériau enregistré. Les affectations des corps sont inchangées.",
"material.editor.scope": "Enregistrer stocke ce matériau sans l'affecter à des corps. Les propriétés et conditions vides restent inconnues. La masse volumique de la fiche ne modifie ni la masse volumique affectée ni la masse.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Aparencia conservada",
"material.editor.public_reference": "Referencia pública",
"material.editor.reference": "Referencia",
"material.editor.remove": "Quitar",
"material.editor.save_or_discard": "Garda ou descarta o borrador do material antes de asignar un material.",
"material.editor.saved": "Material gardado. As asignacións aos corpos non cambian.",
"material.editor.scope": "Gardar almacena este material sen asignalo a corpos. As propiedades e condicións en branco seguen sendo descoñecidas. A densidade da ficha non cambia a densidade asignada nin a masa.",
+1
View File
@@ -2218,6 +2218,7 @@
"material.editor.preserved_appearance": "מראה שנשמר",
"material.editor.public_reference": "ייחוס ציבורי",
"material.editor.reference": "ייחוס",
"material.editor.remove": "הסרה",
"material.editor.save_or_discard": "יש לשמור או לבטל את טיוטת החומר לפני הקצאת חומר.",
"material.editor.saved": "החומר נשמר. הקצאות הגופים לא השתנו.",
"material.editor.scope": "השמירה מאחסנת את החומר הזה בלי להקצות אותו לגופים. תכונות ותנאים ריקים נשארים לא ידועים. צפיפות הכרטיס אינה משנה את הצפיפות המוקצית או את המסה.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Očuvani izgled",
"material.editor.public_reference": "Javna referenca",
"material.editor.reference": "Referenca",
"material.editor.remove": "Ukloni",
"material.editor.save_or_discard": "Spremite ili odbacite nacrt materijala prije dodjele materijala.",
"material.editor.saved": "Materijal je spremljen. Dodjele tijelima ostaju nepromijenjene.",
"material.editor.scope": "Spremi pohranjuje ovaj materijal bez dodjele tijelima. Prazna svojstva i uvjeti ostaju nepoznati. Gustoća s kartice ne mijenja dodijeljenu gustoću ni masu.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Megőrzött megjelenés",
"material.editor.public_reference": "Nyilvános hivatkozás",
"material.editor.reference": "Hivatkozás",
"material.editor.remove": "Eltávolítás",
"material.editor.save_or_discard": "Anyag hozzárendelése előtt mentse vagy vesse el az anyagvázlatot.",
"material.editor.saved": "Az anyag mentve. A testekhez rendelt anyagok nem változtak.",
"material.editor.scope": "A Mentés az anyagot a testekhez való hozzárendelés nélkül tárolja. Az üres tulajdonságok és feltételek ismeretlenek maradnak. A kártyán szereplő sűrűség nem változtatja meg a hozzárendelt sűrűséget vagy a tömeget.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Tampilan yang dipertahankan",
"material.editor.public_reference": "Referensi publik",
"material.editor.reference": "Referensi",
"material.editor.remove": "Hapus",
"material.editor.save_or_discard": "Simpan atau buang draf material sebelum menetapkan material.",
"material.editor.saved": "Material disimpan. Penetapan ke bodi tidak berubah.",
"material.editor.scope": "Simpan menyimpan material ini tanpa menetapkannya ke bodi. Properti dan kondisi yang dikosongkan tetap dianggap tidak diketahui. Massa jenis pada kartu tidak mengubah massa jenis atau massa yang sudah ditetapkan.",
+1
View File
@@ -1896,6 +1896,7 @@
"material.editor.preserved_appearance": "Aspetto conservato",
"material.editor.public_reference": "Riferimento pubblico",
"material.editor.reference": "Riferimento",
"material.editor.remove": "Rimuovi",
"material.editor.save_or_discard": "Salva o scarta la bozza del materiale prima di assegnare un materiale.",
"material.editor.saved": "Materiale salvato. Le assegnazioni ai corpi non sono cambiate.",
"material.editor.scope": "Salva memorizza questo materiale senza assegnarlo ai corpi. Le proprietà e le condizioni lasciate vuote restano sconosciute. La densità della scheda non cambia la densità assegnata né la massa.",
+1
View File
@@ -2218,6 +2218,7 @@
"material.editor.preserved_appearance": "保持される外観",
"material.editor.public_reference": "公開資料",
"material.editor.reference": "参照",
"material.editor.remove": "削除",
"material.editor.save_or_discard": "材料を割り当てる前に下書きを保存または破棄してください。",
"material.editor.saved": "材料を保存しました。ボディへの割り当ては変わりません。",
"material.editor.scope": "保存してもボディには割り当てません。空欄の特性と条件は不明のままです。カードの密度は割り当て密度や質量を変更しません。",
+1
View File
@@ -1939,6 +1939,7 @@
"material.editor.preserved_appearance": "შენარჩუნებული იერსახე",
"material.editor.public_reference": "საჯარო წყარო",
"material.editor.reference": "საცნობარო",
"material.editor.remove": "წაშლა",
"material.editor.save_or_discard": "მასალის მინიჭებამდე შეინახეთ ან გააუქმეთ მასალის მონახაზი.",
"material.editor.saved": "მასალა შენახულია. სხეულებზე მინიჭებები არ შეცვლილა.",
"material.editor.scope": "შენახვა ამ მასალას ინახავს სხეულებზე მინიჭების გარეშე. ცარიელი თვისებები და პირობები უცნობად რჩება. ბარათის სიმკვრივე არ ცვლის მინიჭებულ სიმკვრივეს ან მასას.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Penampilan dikekalkan",
"material.editor.public_reference": "Rujukan awam",
"material.editor.reference": "Rujukan",
"material.editor.remove": "Buang",
"material.editor.save_or_discard": "Simpan atau buang draf bahan sebelum menetapkan bahan.",
"material.editor.saved": "Bahan disimpan. Penetapan jasad tidak berubah.",
"material.editor.scope": "Simpan menyimpan bahan ini tanpa menetapkannya pada jasad. Sifat dan keadaan yang dibiarkan kosong kekal tidak diketahui. Ketumpatan kad tidak mengubah ketumpatan yang ditetapkan atau jisim.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Bevart utseende",
"material.editor.public_reference": "Offentlig referanse",
"material.editor.reference": "Referanse",
"material.editor.remove": "Fjern",
"material.editor.save_or_discard": "Lagre eller forkast materialutkastet før du tildeler et materiale.",
"material.editor.saved": "Materialet er lagret. Kroppstildelinger er uendret.",
"material.editor.scope": "Ved lagring lagres materialet uten at det tildeles kropper. Tomme egenskaper og betingelser forblir ukjente. Tettheten på kortet endrer ikke tildelt tetthet eller masse.",
+1
View File
@@ -1893,6 +1893,7 @@
"material.editor.preserved_appearance": "Behouden uiterlijk",
"material.editor.public_reference": "Openbare referentie",
"material.editor.reference": "Referentie",
"material.editor.remove": "Verwijderen",
"material.editor.save_or_discard": "Sla het materiaalconcept op of verwerp het voordat je een materiaal toewijst.",
"material.editor.saved": "Materiaal opgeslagen. Toewijzingen aan lichamen zijn ongewijzigd.",
"material.editor.scope": "Opslaan bewaart dit materiaal zonder het aan lichamen toe te wijzen. Lege eigenschappen en voorwaarden blijven onbekend. De dichtheid op de kaart wijzigt de toegewezen dichtheid of massa niet.",
+1
View File
@@ -2204,6 +2204,7 @@
"material.editor.preserved_appearance": "Zachowany wygląd",
"material.editor.public_reference": "Publiczne źródło",
"material.editor.reference": "Źródło",
"material.editor.remove": "Usuń",
"material.editor.save_or_discard": "Zapisz lub odrzuć szkic materiału przed przypisaniem materiału.",
"material.editor.saved": "Zapisano materiał. Przypisania do brył pozostają bez zmian.",
"material.editor.scope": "Zapisz przechowuje ten materiał bez przypisywania go do brył. Puste właściwości i warunki pozostają nieznane. Gęstość z karty nie zmienia przypisanej gęstości ani masy.",
+1
View File
@@ -2204,6 +2204,7 @@
"material.editor.preserved_appearance": "Aparência preservada",
"material.editor.public_reference": "Referência pública",
"material.editor.reference": "Referência",
"material.editor.remove": "Remover",
"material.editor.save_or_discard": "Salve ou descarte o rascunho do material antes de atribuir um material.",
"material.editor.saved": "Material salvo. As atribuições dos corpos não mudam.",
"material.editor.scope": "Salvar armazena este material sem atribuí-lo a corpos. Propriedades e condições em branco permanecem desconhecidas. A densidade da ficha não altera a densidade atribuída nem a massa.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Aspect păstrat",
"material.editor.public_reference": "Referință publică",
"material.editor.reference": "Referință",
"material.editor.remove": "Elimină",
"material.editor.save_or_discard": "Salvați sau renunțați la ciorna materialului înainte de a atribui un material.",
"material.editor.saved": "Material salvat. Atribuirile pe corpuri rămân neschimbate.",
"material.editor.scope": "Salvarea stochează acest material fără a-l atribui corpurilor. Proprietățile și condițiile lăsate necompletate rămân necunoscute. Densitatea din fișă nu modifică densitatea atribuită și nici masa.",
+1
View File
@@ -1882,6 +1882,7 @@
"material.editor.preserved_appearance": "Сохранённый внешний вид",
"material.editor.public_reference": "Общедоступный справочник",
"material.editor.reference": "Ссылка",
"material.editor.remove": "Убрать",
"material.editor.save_or_discard": "Сохраните или отмените черновик материала, прежде чем назначать материал.",
"material.editor.saved": "Материал сохранён. Назначения телам не изменились.",
"material.editor.scope": "Сохранение записывает этот материал, не назначая его телам. Пустые свойства и условия остаются неизвестными. Плотность в карточке не меняет назначенную плотность и массу.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Сачувани изглед",
"material.editor.public_reference": "Јавна референца",
"material.editor.reference": "Референца",
"material.editor.remove": "Уклони",
"material.editor.save_or_discard": "Сачувајте или одбаците нацрт материјала пре доделе материјала.",
"material.editor.saved": "Материјал је сачуван. Доделе телима остају непромењене.",
"material.editor.scope": "Сачувај чува овај материјал без доделе телима. Празна својства и услови остају непознати. Густина са картице не мења додељену густину ни масу.",
+1
View File
@@ -1890,6 +1890,7 @@
"material.editor.preserved_appearance": "Bevarat utseende",
"material.editor.public_reference": "Offentlig referens",
"material.editor.reference": "Referens",
"material.editor.remove": "Ta bort",
"material.editor.save_or_discard": "Spara eller förkasta materialutkastet innan du tilldelar ett material.",
"material.editor.saved": "Materialet sparades. Kropparnas tilldelningar är oförändrade.",
"material.editor.scope": "Spara lagrar materialet utan att tilldela det till kroppar. Tomma egenskaper och förhållanden förblir okända. Kortets densitet ändrar inte tilldelad densitet eller massa.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Korunan görünüm",
"material.editor.public_reference": "Herkese açık referans",
"material.editor.reference": "Referans",
"material.editor.remove": "Kaldır",
"material.editor.save_or_discard": "Malzeme atamadan önce malzeme taslağını kaydedin veya atın.",
"material.editor.saved": "Malzeme kaydedildi. Gövde atamaları değişmedi.",
"material.editor.scope": "Kaydetme, bu malzemeyi gövdelere atamadan saklar. Boş özellikler ve koşullar bilinmez kalır. Kart yoğunluğu, atanan yoğunluğu veya kütleyi değiştirmez.",
+1
View File
@@ -2199,6 +2199,7 @@
"material.editor.preserved_appearance": "Збережений вигляд",
"material.editor.public_reference": "Публічний довідник",
"material.editor.reference": "Посилання",
"material.editor.remove": "Вилучити",
"material.editor.save_or_discard": "Збережіть або відхиліть чернетку матеріалу перед призначенням матеріалу.",
"material.editor.saved": "Матеріал збережено. Призначення тілам не змінилися.",
"material.editor.scope": "Збереження записує цей матеріал, не призначаючи його тілам. Порожні властивості й умови лишаються невідомими. Густина в картці не змінює призначену густину чи масу.",
+1
View File
@@ -2218,6 +2218,7 @@
"material.editor.preserved_appearance": "Bề mặt hiển thị giữ lại",
"material.editor.public_reference": "Tài liệu tham khảo công khai",
"material.editor.reference": "Tham khảo",
"material.editor.remove": "Xóa",
"material.editor.save_or_discard": "Lưu hoặc hủy bản nháp vật liệu trước khi gán vật liệu.",
"material.editor.saved": "Đã lưu vật liệu. Gán vật liệu cho khối không đổi.",
"material.editor.scope": "Lưu sẽ lưu trữ vật liệu này mà không gán cho khối. Các thuộc tính và điều kiện để trống vẫn chưa xác định. Khối lượng riêng trên thẻ không làm thay đổi khối lượng riêng hay khối lượng đã gán.",
+1
View File
@@ -1896,6 +1896,7 @@
"material.editor.preserved_appearance": "保留的外觀",
"material.editor.public_reference": "公開參考資料",
"material.editor.reference": "參考",
"material.editor.remove": "刪除",
"material.editor.save_or_discard": "指定材料前,請儲存或放棄材料草稿。",
"material.editor.saved": "材料已儲存。實體的材料指定未變。",
"material.editor.scope": "儲存材料不會將其指定給實體。空白屬性和條件仍為未知。屬性卡密度不會改變指定密度或質量。",
+1
View File
@@ -2218,6 +2218,7 @@
"material.editor.preserved_appearance": "保留的外观",
"material.editor.public_reference": "公开参考资料",
"material.editor.reference": "参考",
"material.editor.remove": "删除",
"material.editor.save_or_discard": "指定材料前,请保存或放弃材料草稿。",
"material.editor.saved": "材料已保存。实体的材料指定未变。",
"material.editor.scope": "保存材料不会将其指定给实体。空白属性和条件仍为未知。属性卡密度不会改变指定密度或质量。",
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "solidcraft-color"
description = "Colour rules and data any crate may share: the sRGB transfer function and the surface analysis palette"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+20
View File
@@ -0,0 +1,20 @@
# solidcraft-color
Colour rules and data that any crate may share. It has no dependencies and holds no app types,
so the renderer, the document, the importers and the UI theme all use the same rule and the same
colours instead of keeping copies.
The sRGB transfer function (IEC 61966-2-1) lives here once: every encode and decode in Rust goes
through `srgb`, so a change to the rule is one change. Shaders can't call Rust and keep their own
copies.
## API
| Item | What it is |
|---|---|
| `srgb::encode(linear)` | Linear light (0..1) to an sRGB value (0..1) |
| `srgb::decode(encoded)` | An sRGB value (0..1) to linear light |
| `srgb::encode8(linear)` | Linear light to the nearest 8-bit sRGB code (out of range clamps, NaN reads as 0) |
| `srgb::decode8(code)` | An 8-bit sRGB code to linear light |
| `AnalysisColors` | The surface analysis palette (draft, accessibility, curvature, hatch), straight sRGB 0..1 as the shader takes it; the theme fills it and the renderer reads it |
| `AnalysisColors::DEFAULT` | The light theme's colours with no colour vision mode, as headless pictures use |
+32
View File
@@ -0,0 +1,32 @@
//! The colours a surface analysis is shaded in.
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
/// reads with hue lost too (#975).
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
impl AnalysisColors {
/// The light theme's colours (no colour vision mode): what headless pictures use.
pub const DEFAULT: AnalysisColors = AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
hatch: false,
};
}
impl Default for AnalysisColors {
fn default() -> Self {
AnalysisColors::DEFAULT
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Colour rules and data shared by every crate: the sRGB transfer function ([`srgb`]) and the surface analysis palette
//! ([`AnalysisColors`]). No app types and no dependencies, so the renderer, the document and the theme all read the same
//! rule and the same colours.
mod analysis;
pub mod srgb;
pub use analysis::AnalysisColors;
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "solidcraft-control-egui"
description = "The window's own control requests any egui app answers alike: ui.screenshot and ui.resize"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solidcraft-control = { workspace = true }
serde_json = { workspace = true }
# The window's own requests (`ui.screenshot`, `ui.resize`) go through egui.
egui = { workspace = true }
# Screenshots are saved in the format their path names.
image = { workspace = true }
+14
View File
@@ -0,0 +1,14 @@
# solidcraft-control-egui
The window's own control requests (docs/control-protocol.md), which any egui app answers the same
way: `ui.screenshot` and `ui.resize`. The token files, the loopback server and the request types
are `solidcraft-control`, which has no UI toolkit, so a headless engine, CLI or MCP bridge uses the
control channel without compiling egui. No app types and no engine.
## API
| Item | What it does |
|---|---|
| `screenshot::Shots` | `ui.screenshot`: frames asked for, taken when the view settles, saved and answered, or refused when hidden, cancelled or late; the app gives the reply for no frame |
| `screenshot::encode` | A frame in the format its path names |
| `window::resize`, `window::hidden` | `ui.resize` over `egui::Context`; whether the window presents frames |
+8
View File
@@ -0,0 +1,8 @@
//! The window's own control requests (docs/control-protocol.md) any app answers alike over egui:
//! `ui.screenshot` ([`screenshot::Shots`]) and `ui.resize` ([`window::resize`]). The token, the
//! server and the requests themselves are `solidcraft-control`, which needs no UI toolkit.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod screenshot;
pub mod window;
+288
View File
@@ -0,0 +1,288 @@
//! `ui.screenshot`: a picture of the window as it is presented, for a control request. The
//! window asks egui for a frame a few frames after the request (a view that just moved settles
//! first), saves it where the request says and answers; a hidden window, a request whose agent
//! chat turn ended, or no frame within 8 s is answered at once with why.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::mpsc::{Receiver, Sender, channel};
use egui::ColorImage;
use serde_json::{Value, json};
use solidcraft_control::{cancelled_chat_response, chat_turn_cancelled};
use crate::window::hidden;
/// A frame asked for: its token, the time (ms) from which it may be taken, and the frames drawn
/// since it was asked for.
pub type Queued = (u64, f64, u32);
/// A request waiting for its frame: its token, where to save it, where to answer, and the time
/// (ms) after which no frame is coming.
pub type Pending = (u64, Option<String>, Sender<Value>, f64);
/// A frame egui handed back, by its token.
pub type Shot = (u64, Arc<ColorImage>);
/// The window's screenshots in flight. The app may queue frames of its own (an issue report's)
/// with tokens from [`Shots::token`]; [`Shots::collect`] hands those back.
pub struct Shots {
/// The error a request gets when the window presents no frame: the app's words, which may
/// name its other ways to a picture.
no_frame: String,
/// The last token given out.
pub token: u64,
/// Frames asked for and not yet requested from egui.
pub queued: Vec<Queued>,
/// Control requests waiting for their frame.
pub pending: Vec<Pending>,
/// The agent chat turn of each pending request that belongs to one.
turns: HashMap<u64, Arc<AtomicBool>>,
/// Frames egui hands back; egui calls back, [`Shots::collect`] reads them.
tx: Sender<Shot>,
rx: Receiver<Shot>,
}
impl Shots {
/// No screenshots in flight; a request the window presents no frame for is answered with the
/// error `no_frame`.
pub fn new(no_frame: impl Into<String>) -> Self {
let (tx, rx) = channel();
Shots { no_frame: no_frame.into(), token: 0, queued: Vec::new(), pending: Vec::new(), turns: HashMap::new(), tx, rx }
}
/// Take the window's picture for a request that answers on `reply`, saved at `path`; `turn`:
/// the agent chat turn it belongs to. `now` is in ms. A window that is minimised or covered
/// (another macOS Space) presents no frame: that is said at once rather than after the
/// deadline (#1677).
pub fn request(&mut self, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>, turn: Option<Arc<AtomicBool>>, now: f64) {
if hidden(ctx) {
let _ = reply.send(json!({"ok": false, "hidden": true, "error": self.no_frame}));
return;
}
self.token += 1;
let token = self.token;
if let Some(turn) = turn {
self.turns.insert(token, turn);
}
self.queued.push((token, now + 150.0, 0));
self.pending.push((token, path, reply, now + 8000.0));
}
/// Each frame: ask egui for the frames that are due, three frames after their request at the
/// earliest. `settled`: the view is drawn at full quality; a view that keeps moving is taken
/// as it is half a second later (#7165).
pub fn issue(&mut self, ctx: &egui::Context, settled: bool, now: f64) {
let shot_tx = &self.tx;
self.queued.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 && (settled || now >= *at + 500.0) {
let (token, tx, ctx2) = (*token, shot_tx.clone(), ctx.clone());
ctx.request_screenshot(move |image| {
let _ = tx.send((token, image));
ctx2.request_repaint();
});
false
} else {
true
}
});
if !self.queued.is_empty() || !self.pending.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// Each frame: answer the requests whose frame came, saving it with `save` (the reply), and
/// those whose turn ended or that wait in vain. `others`: the app waits for frames of its own;
/// then every frame that came this frame is handed back, for the app to take its own from.
pub fn collect(&mut self, ctx: &egui::Context, now: f64, others: bool, save: impl Fn(&ColorImage, Option<&str>) -> Value) -> Vec<Shot> {
// A screenshot requested in one tab must never return or save a frame of another.
let turns = &mut self.turns;
let queued = &mut self.queued;
self.pending.retain(|(token, _, reply, _)| {
if chat_turn_cancelled(turns.get(token).map(Arc::as_ref)) {
turns.remove(token);
queued.retain(|(waiting, ..)| waiting != token);
let _ = reply.send(cancelled_chat_response());
false
} else {
true
}
});
// Drained every frame: a shot that comes after its request was cancelled or timed out
// is dropped here instead of waiting in the channel.
let shots: Vec<Shot> = self.rx.try_iter().collect();
if self.pending.is_empty() && !others {
return Vec::new();
}
for (token, image) in &shots {
if let Some(i) = self.pending.iter().position(|(t, ..)| t == token) {
let (_, path, reply, _) = self.pending.remove(i);
self.turns.remove(token);
let _ = reply.send(save(image, path.as_deref()));
}
}
// Hidden while waiting: no frame is coming.
let hidden = hidden(ctx);
let (turns, no_frame) = (&mut self.turns, &self.no_frame);
self.pending.retain(|(token, _, reply, deadline)| {
if now < *deadline && !hidden {
return true;
}
let _ = reply.send(json!({"ok": false, "error": no_frame}));
turns.remove(token);
false
});
shots
}
}
/// `image` in the format `path`'s extension names (a PNG for `.png`).
pub fn encode(image: &ColorImage, path: &str) -> Result<Vec<u8>, String> {
let [w, h] = image.size;
let mut buf = Vec::with_capacity(w * h * 4);
for c in &image.pixels {
buf.extend_from_slice(&c.to_array());
}
let img = image::RgbaImage::from_raw(w as u32, h as u32, buf).ok_or("bad image")?;
let format = image::ImageFormat::from_path(path).map_err(|e| e.to_string())?;
let mut bytes = std::io::Cursor::new(Vec::new());
img.write_to(&mut bytes, format).map_err(|e| e.to_string())?;
Ok(bytes.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
/// The app's reply for no frame.
const NO_FRAME: &str = "no frame here";
fn pixel_image() -> Arc<ColorImage> {
Arc::new(ColorImage::new([4, 3], vec![egui::Color32::from_rgb(10, 20, 30); 12]))
}
/// A request is answered with its frame once egui hands it back, saved as the app saves it;
/// a frame of the app's own is handed back to it; a request whose frame never comes is
/// answered after its deadline.
#[test]
fn a_request_is_answered_with_its_frame_or_its_deadline() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
shots.request(&ctx, Some("a.png".into()), reply, None, 0.0);
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (1, 1, 1));
// The frame egui hands back, and one the app asked for itself.
shots.queued.clear();
let _ = shots.tx.send((1, pixel_image()));
let _ = shots.tx.send((2, pixel_image()));
let save = |image: &ColorImage, path: Option<&str>| json!({"ok": true, "result": {"path": path, "width": image.size[0]}});
let back = shots.collect(&ctx, 1.0, true, save);
assert_eq!(back.iter().map(|(t, _)| *t).collect::<Vec<_>>(), [1, 2]);
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": true, "result": {"path": "a.png", "width": 4}})));
assert!(shots.pending.is_empty());
let (reply, late) = channel();
shots.request(&ctx, None, reply, None, 0.0);
assert!(shots.collect(&ctx, 7999.0, false, save).is_empty());
assert!(late.try_recv().is_err(), "still waiting");
shots.collect(&ctx, 8000.0, false, save);
assert_eq!(late.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
}
/// A request whose agent chat turn ended is answered so, and its frame is never taken.
#[test]
fn a_request_whose_turn_ended_is_refused() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
let turn = Arc::new(AtomicBool::new(true));
shots.request(&ctx, None, reply, Some(Arc::clone(&turn)), 0.0);
turn.store(false, std::sync::atomic::Ordering::Relaxed);
shots.collect(&ctx, 1.0, false, |_, _| Value::Null);
assert_eq!(answer.try_recv().ok(), Some(cancelled_chat_response()));
assert!(shots.queued.is_empty() && shots.pending.is_empty());
}
/// One frame of a window that is minimised (`hidden`) or shown, running `f`.
fn frame(ctx: &egui::Context, hidden: bool, f: impl FnMut(&egui::Context)) {
let mut raw = egui::RawInput::default();
let info = egui::ViewportInfo { minimized: Some(hidden), occluded: Some(false), ..Default::default() };
raw.viewports.insert(egui::ViewportId::ROOT, info);
// No painter takes the frame's textures here.
ctx.run_pass(raw, f).textures_delta.clear();
}
/// A frame is asked of egui 150 ms after its request at the earliest and on its third frame;
/// a view that keeps moving is taken as it is 500 ms after that.
#[test]
fn a_frame_is_taken_once_the_view_settles() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, _answer) = channel();
shots.request(&ctx, None, reply.clone(), None, 0.0);
shots.issue(&ctx, true, 200.0);
shots.issue(&ctx, true, 200.0);
assert_eq!(shots.queued.len(), 1, "two frames drawn");
shots.issue(&ctx, true, 200.0);
assert!(shots.queued.is_empty(), "the third frame is taken");
shots.request(&ctx, None, reply.clone(), None, 0.0);
for _ in 0..3 {
shots.issue(&ctx, true, 149.0);
}
assert_eq!(shots.queued.len(), 1, "too soon");
shots.issue(&ctx, true, 150.0);
assert!(shots.queued.is_empty());
shots.request(&ctx, None, reply, None, 0.0);
for _ in 0..3 {
shots.issue(&ctx, false, 649.0);
}
assert_eq!(shots.queued.len(), 1, "still moving");
shots.issue(&ctx, false, 650.0);
assert!(shots.queued.is_empty(), "taken as it is");
assert_eq!(shots.pending.len(), 3, "every request still waits for its frame");
}
/// A hidden window refuses a request at once, and answers one that waits when it hides.
#[test]
fn a_hidden_window_answers_at_once() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
frame(&ctx, true, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "hidden": true, "error": NO_FRAME})));
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (0, 0, 0));
frame(&ctx, false, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
frame(&ctx, false, |ctx| {
shots.collect(ctx, 1.0, false, |_, _| Value::Null);
});
assert!(answer.try_recv().is_err(), "shown: still waiting");
frame(&ctx, true, |ctx| {
shots.collect(ctx, 2.0, false, |_, _| Value::Null);
});
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
assert!(shots.pending.is_empty());
}
/// With nothing waiting, the frames that came are dropped, not kept for a later request.
#[test]
fn frames_nobody_waits_for_are_dropped() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let _ = shots.tx.send((5, pixel_image()));
assert!(shots.collect(&ctx, 0.0, false, |_, _| Value::Null).is_empty());
assert!(shots.collect(&ctx, 1.0, true, |_, _| Value::Null).is_empty(), "the frame was dropped");
}
/// The saved bytes are the format the path names.
#[test]
fn a_png_path_gets_a_png() {
let bytes = encode(&pixel_image(), "shot.png").unwrap();
assert!(bytes.starts_with(b"\x89PNG"), "{:?}", bytes.get(..8));
assert!(encode(&pixel_image(), "shot.nope").is_err());
}
}
+19
View File
@@ -0,0 +1,19 @@
//! The window's own control requests over `egui::Context`, which any app answers the same way.
use serde_json::Value;
/// The window is minimised or fully covered (another macOS Space), so it presents no frames
/// (#1677). Unknown counts as shown: the screenshot deadline still ends a wait for a frame that
/// never comes.
pub fn hidden(ctx: &egui::Context) -> bool {
ctx.input(|i| i.viewport().visible()) == Some(false)
}
/// `ui.resize {width, height}`: the window's inner size in points, kept within 320×240 and
/// 8192×8192.
pub fn resize(ctx: &egui::Context, params: &Value) -> Result<Value, String> {
let f = |k: &str| params.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let (Some(w), Some(h)) = (f("width"), f("height")) else { return Err("missing width/height".into()) };
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
Ok(Value::Null)
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "solidcraft-control"
description = "The control channel any app may share: token files, the loopback server, and its requests; no UI toolkit"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
# The loopback server is for desktop apps; the web app is driven through the page instead.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The per-launch control token: 32 random bytes from the operating system (MIT OR Apache-2.0).
getrandom = "0.3"
+23
View File
@@ -0,0 +1,23 @@
# solidcraft-control
The control channel any app may share (docs/control-protocol.md). An app that listens for
control makes a random token per launch, writes it to a file only the user can read and refuses
every connection whose first request doesn't carry it; a client finds the token the same way.
Which app the files belong to, its environment, its name in messages and its default port come
from an `App` value the app defines. No app types and no engine.
## API
| Item | What it does |
|---|---|
| `auth::App` | An app's identity: `name`, `data_dir`, `env` (its prefixed variables), `utc`, `default_port` |
| `App::token_path(port)`, `App::current_port_path()` | `<data folder>/control/<port>.token` and the discovery file (`CONTROL_DIR` replaces the folder) |
| `App::write_token_file(path, token)` | Write a token readable only by the user, with its `<port>.pid` file beside it |
| `App::write_current_port`, `App::read_current_port`, `App::remove_current_port` | The discovery file of the most recently started app |
| `App::env_token()`, `App::client_token(port, explicit)` | The token a client sends: explicit, `CONTROL_TOKEN`, else the token file |
| `auth::ClientToken::explain_error(error)` | A refusal with the token file named and whether it is stale; never the token |
| `auth::plan`, `auth::Listen`, `auth::bind_with_fallback`, `auth::PORT_TRIES` | Where a launch listens; a loopback listener on the first free port |
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
+665
View File
@@ -0,0 +1,665 @@
//! The control channel's per-launch token (docs/control-protocol.md). An app listening for
//! control makes a random token, writes it to a file only the user can read
//! ([`App::token_path`]) and refuses every connection whose first request doesn't carry it, so
//! other users on the same machine (loopback is shared, on Windows across remote-desktop sessions
//! too) can't drive the app. Clients find it with [`App::client_token`]. Next to it, `<port>.pid`
//! names the process that wrote it, so a client refused with a leftover token can say the file is
//! stale.
use std::ffi::OsString;
use std::io::{Error, ErrorKind, Read, Result, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Shortest token accepted from the app's `CONTROL_TOKEN` variable (hex characters).
pub const MIN_TOKEN_LEN: usize = 32;
/// Ports tried from the app's default port upward when it is taken (a second window, another
/// program).
pub const PORT_TRIES: u16 = 16;
/// Which app a control channel belongs to: where its token files live, its environment and its
/// name in messages.
#[derive(Clone, Copy)]
pub struct App {
/// The app's name in messages ("restart SolidCraft").
pub name: &'static str,
/// The per-user data folder; the token files go in its `control` folder.
pub data_dir: fn() -> Option<PathBuf>,
/// The app's environment variable `name` (`CONTROL_DIR`, `CONTROL_TOKEN`), with the app's prefix.
pub env: fn(&str) -> Option<OsString>,
/// A time in seconds since the Unix epoch, as the app writes UTC times in messages.
pub utc: fn(u64) -> String,
/// The port the control channel tries first when the launch gives none. Below the ephemeral
/// ranges of Linux (32768+) and Windows/macOS (49152+), so the system never hands it to an
/// outgoing connection.
pub default_port: u16,
}
/// Where the control channel listens, from the launch's `--control`/environment port and the app's
/// allow-agents setting.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Listen {
/// Exactly this port (`--control PORT`, the app's `CONTROL_PORT`); no fallback.
Exact(u16),
/// The app's [`App::default_port`], or the next free one of [`PORT_TRIES`].
Default,
}
/// What a launch listens on. An explicit port wins over the app's allow-agents setting
/// (`enabled`), so a scripted launch works with the setting off; with the setting off and no port,
/// nothing listens.
pub fn plan(explicit: Option<u16>, enabled: bool) -> Option<Listen> {
match explicit {
Some(port) => Some(Listen::Exact(port)),
None if enabled => Some(Listen::Default),
None => None,
}
}
/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to
/// `tries` ports in all.
pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListener> {
let mut last = Error::new(ErrorKind::AddrInUse, "no port to try");
for port in (first..=u16::MAX).take(usize::from(tries.max(1))) {
match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => return Ok(l),
// In use; Windows reports a port another program holds exclusively as denied.
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e,
Err(e) => return Err(e),
}
}
Err(last)
}
impl App {
/// The folder of the token files: `<data folder>/control` (the app's `CONTROL_DIR` replaces it).
fn control_dir(&self) -> Option<PathBuf> {
match self.custom_control_dir() {
Some(d) => Some(PathBuf::from(d)),
None => Some((self.data_dir)()?.join("control")),
}
}
/// The folder the app's `CONTROL_DIR` names in place of the default one.
fn custom_control_dir(&self) -> Option<OsString> {
(self.env)("CONTROL_DIR").filter(|d| !d.is_empty())
}
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
/// (the app's `CONTROL_DIR` replaces the folder).
pub fn token_path(&self, port: u16) -> Option<PathBuf> {
Some(self.control_dir()?.join(format!("{port}.token")))
}
/// The discovery file: the port of the most recently started app, so a client with no port
/// finds it. It holds only the port; the token stays in its own file.
pub fn current_port_path(&self) -> Option<PathBuf> {
Some(self.control_dir()?.join("current.port"))
}
/// Record `port` as the running app's, readable only by the user like the token files.
pub fn write_current_port(&self, port: u16) -> Result<PathBuf> {
let path = self.current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?;
self.write_token_file(&path, &port.to_string())?;
Ok(path)
}
/// The port the discovery file names, if there is one.
pub fn read_current_port(&self) -> Option<u16> {
parse_port(&std::fs::read_to_string(self.current_port_path()?).ok().filter(|t| t.len() <= 64)?)
}
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
/// over: it names another port, or another launch now holds this port's token file. Under
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
/// number and write the same file names; the one closing first must leave the other's (#8794).
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
pub fn remove_current_port(&self, port: u16, token: &str) {
if let (Some(current), Some(file)) = (self.current_port_path(), self.token_path(port)) {
remove_discovery(&current, &file, port, token);
}
}
/// A token set on purpose with the app's `CONTROL_TOKEN` (test harnesses, sandboxed packages
/// whose data folder the client can't see), if it is long enough.
pub fn env_token(&self) -> Option<String> {
(self.env)("CONTROL_TOKEN").and_then(|v| v.into_string().ok()).map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
}
/// The token a client sends to the app on `port`: `explicit` (`--token`), else the app's
/// `CONTROL_TOKEN`, else the app's token file.
pub fn client_token(&self, port: u16, explicit: Option<&str>) -> Option<ClientToken> {
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
return Some(ClientToken { token: t.to_string(), file: None, app: *self });
}
if let Some(t) = self.env_token() {
return Some(ClientToken { token: t, file: None, app: *self });
}
// Read directly, not through any agent's file scope: the app's own file.
read_token_file(&self.token_path(port)?, *self)
}
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file
/// is removed and no token is written. A file left by an earlier launch is replaced. The process
/// file beside it ([`pid_path`]) records this process, best effort.
pub fn write_token_file(&self, path: &Path, token: &str) -> Result<()> {
#[cfg(windows)]
let restrict = |p: &Path| restrict_to_user(p, self.custom_control_dir().is_some());
#[cfg(not(windows))]
let restrict = |_: &Path| Ok(());
write_token_file_with(path, token, restrict)
}
}
fn parse_port(text: &str) -> Option<u16> {
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
}
/// [`App::remove_current_port`] on given files: `current` is the discovery file, `file` the token
/// file of `port`.
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
if named != Some(port) || held_by_another(file, token) {
return;
}
let _ = std::fs::remove_file(current);
let _ = std::fs::remove_file(pid_path(current));
}
/// A client token and, when it came from a token file, where that file is.
pub struct ClientToken {
pub token: String,
pub file: Option<TokenSource>,
/// The app it is for, named in an explanation.
pub app: App,
}
/// The token file a client read: its path, when it was written and the process that wrote it.
pub struct TokenSource {
pub path: PathBuf,
pub modified: Option<SystemTime>,
/// From `<port>.pid`: the writer's process id and, on Linux, its start time.
pub writer: Option<(u32, Option<u64>)>,
}
impl ClientToken {
/// The app's `error`, and for a refused token read from a file, which file it was, when it
/// was written and whether the instance that wrote it has exited. Never the token itself.
pub fn explain_error(&self, error: &str) -> String {
let Some(src) = &self.file else { return error.to_string() };
if !error.starts_with("unauthorized") {
return error.to_string();
}
let written = src
.modified
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|t| (self.app.utc)(t.as_secs()))
.unwrap_or_else(|| "at an unknown time".into());
let age = src.modified.and_then(|t| t.elapsed().ok()).map(|d| format!(" ({} s ago)", d.as_secs())).unwrap_or_default();
let path = src.path.display();
let name = self.app.name;
match src.writer.filter(|(pid, start)| writer_exited(*pid, *start)) {
Some((pid, _)) => format!(
"{error}; stale token file from an instance that exited (process {pid}): {path}, written {written}{age}; delete that file or restart {name}"
),
None => format!("{error}; token from {path}, written {written}{age}; if {name} was force-quit, restart it or delete that file"),
}
}
}
/// Files bigger than this are not token files.
const MAX_TOKEN_FILE: u64 = 4096;
fn read_token_file(path: &Path, app: App) -> Option<ClientToken> {
let f = std::fs::File::open(path).ok()?;
let modified = f.metadata().ok().and_then(|m| m.modified().ok());
let mut text = String::new();
f.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
if text.len() as u64 > MAX_TOKEN_FILE {
return None;
}
let token = text.trim();
if token.is_empty() {
return None;
}
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let writer = read_pid_file(&pid_path(&path));
Some(ClientToken { token: token.to_string(), file: Some(TokenSource { path, modified, writer }), app })
}
/// The process file beside the token file `path`: `7878.token` → `7878.pid`.
pub fn pid_path(path: &Path) -> PathBuf {
path.with_extension("pid")
}
/// `<pid> [<start time>]`; anything else (or no file) is no information.
fn read_pid_file(path: &Path) -> Option<(u32, Option<u64>)> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(256).read_to_string(&mut text).ok()?;
let mut fields = text.split_whitespace();
let pid = fields.next()?.parse::<u32>().ok().filter(|&pid| pid > 0)?;
Some((pid, fields.next().and_then(|s| s.parse().ok())))
}
/// Whether the process that wrote a token file is known to be gone: not running, or its id now
/// belongs to a process that started at another time. Unknown counts as running.
fn writer_exited(pid: u32, start: Option<u64>) -> bool {
process_running(pid) == Some(false) || start.zip(process_start(pid)).is_some_and(|(saved, now)| saved != now)
}
/// Whether process `pid` runs; `None` when that can't be found out.
fn process_running(pid: u32) -> Option<bool> {
#[cfg(target_os = "linux")]
{
if !Path::new("/proc/self").exists() {
return None;
}
match std::fs::metadata(format!("/proc/{pid}")) {
Ok(_) => Some(true),
Err(e) if e.kind() == ErrorKind::NotFound => Some(false),
Err(_) => None,
}
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().ok()?;
if output.status.success() {
return Some(true);
}
(output.status.code() == Some(1) && output.stdout.iter().all(u8::is_ascii_whitespace)).then_some(false)
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.creation_flags(0x0800_0000)
.output()
.ok()?;
if !output.status.success() || output.stdout.is_empty() {
return None;
}
// `"image","pid",…` per process; with no match, a message line without a pid.
let text = String::from_utf8_lossy(&output.stdout);
Some(text.lines().any(|line| line.split("\",\"").nth(1).and_then(|s| s.trim_matches('"').parse::<u32>().ok()) == Some(pid)))
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
None
}
}
/// Linux: the process's start time (clock ticks after boot), which tells a reused id apart.
fn process_start(pid: u32) -> Option<u64> {
#[cfg(target_os = "linux")]
{
let mut stat = String::new();
std::fs::File::open(format!("/proc/{pid}/stat")).ok()?.take(4096).read_to_string(&mut stat).ok()?;
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
None
}
}
/// `a == b`, taking the same time wherever they differ.
pub fn tokens_match(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// [`App::write_token_file`], with `restrict` limiting the new file to the user (Windows).
fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
std::fs::create_dir_all(dir)?;
let _ = std::fs::remove_file(path);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
opts.mode(0o600);
}
let opened = opts.open(path)?;
// On a refusal the file goes with the closure, so it is closed before it is removed.
let mut f = match restrict(path).map(|()| opened) {
Ok(f) => f,
Err(e) => {
let _ = std::fs::remove_file(path);
return Err(e);
}
};
f.write_all(token.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
let pid = std::process::id();
let line = process_start(pid).map_or_else(|| format!("{pid}\n"), |start| format!("{pid} {start}\n"));
let _ = std::fs::write(pid_path(path), line);
Ok(())
}
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
/// Another launch may have written the same file since (a second window on the same port,
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
/// has the same one, but each launch's token is its own.
pub fn remove_token_file(path: &Path, token: &str) {
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(pid_path(path));
}
}
/// The token a token file holds, trimmed; `None` when there is no readable token file.
fn read_token(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
let token = text.trim();
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
}
/// Whether the token file at `path` holds a token other than `token`: another launch's.
fn held_by_another(path: &Path, token: &str) -> bool {
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
}
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
/// is left suspended (security software holding it), and waiting on it without a limit froze
/// the app.
#[cfg(windows)]
const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
/// How a run of `icacls` ended.
#[cfg(any(windows, test))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum IcaclsEnd {
Succeeded,
Failed,
/// It didn't finish within [`ICACLS_LIMIT`] and was ended.
TimedOut,
}
/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is
/// in the folder the app's `CONTROL_DIR` names. A timeout is accepted only in the default folder,
/// the user's own local app data, which other users can't read by default; nothing is known about
/// the permissions of any other folder.
#[cfg(any(windows, test))]
fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> {
match end {
IcaclsEnd::Succeeded => Ok(()),
IcaclsEnd::TimedOut if !custom_folder => Ok(()),
IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))),
IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))),
}
}
/// Windows: drop inherited entries and grant only the current user full access. An `icacls`
/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means.
#[cfg(windows)]
fn restrict_to_user(path: &Path, custom_folder: bool) -> Result<()> {
use std::os::windows::process::CommandExt;
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
let child = std::process::Command::new("icacls")
.arg(path)
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.creation_flags(0x0800_0000)
.spawn()?;
let end = match wait_within(child, ICACLS_LIMIT)? {
Some(status) if status.success() => IcaclsEnd::Succeeded,
Some(_) => IcaclsEnd::Failed,
None => IcaclsEnd::TimedOut,
};
restriction_result(end, custom_folder, path)
}
/// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it
/// hasn't finished by then.
#[cfg(any(windows, test))]
fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Result<Option<std::process::ExitStatus>> {
let start = std::time::Instant::now();
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
if start.elapsed() >= limit {
// Not waited for afterwards: a suspended process may not end at once.
let _ = child.kill();
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Removes the token file (path, token) when the app ends, while it still holds that token.
pub struct TokenFile(pub PathBuf, pub String);
impl Drop for TokenFile {
fn drop(&mut self) {
remove_token_file(&self.0, &self.1);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// An app with no data folder and no environment, whose times read `t<seconds>`.
const TEST: App = App { name: "TestCraft", data_dir: || None, env: |_| None, utc: |secs| format!("t{secs}"), default_port: 27878 };
/// #6145: a child that doesn't finish in time is given up on, not waited for forever.
#[cfg(unix)]
#[test]
fn a_slow_child_is_waited_for_only_so_long() {
use std::time::{Duration, Instant};
let start = Instant::now();
let slow = std::process::Command::new("sleep").arg("30").spawn().unwrap();
assert!(wait_within(slow, Duration::from_millis(200)).unwrap().is_none());
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed());
let quick = std::process::Command::new("true").spawn().unwrap();
assert!(wait_within(quick, Duration::from_secs(5)).unwrap().is_some_and(|s| s.success()));
let failing = std::process::Command::new("false").spawn().unwrap();
assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success()));
}
/// #6145's freeze fix stays in the default folder, the user's own; a folder picked with
/// the app's `CONTROL_DIR` gets no token unless the file was really restricted.
#[test]
fn only_the_default_folder_accepts_an_icacls_that_timed_out() {
let path = Path::new("control/7878.token");
for custom in [false, true] {
assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok());
assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err());
}
assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok());
let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err();
assert_eq!(e.kind(), ErrorKind::TimedOut);
assert!(e.to_string().contains("7878.token"), "{e}");
}
/// A file that couldn't be restricted is removed, empty and with no process file beside it, and
/// the error goes to the caller. A restricted one gets its token.
#[test]
fn a_token_file_that_could_not_be_restricted_is_removed() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id()));
let path = dir.join("7879.token");
let e = write_token_file_with(&path, "secret-token", |p| {
assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in");
restriction_result(IcaclsEnd::TimedOut, true, p)
})
.unwrap_err();
assert_eq!(e.kind(), ErrorKind::TimedOut);
assert!(!path.exists() && !pid_path(&path).exists());
write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap();
assert_eq!(read_token(&path).as_deref(), Some("secret-token"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tokens_compare_whole() {
assert!(tokens_match("abc123", "abc123"));
assert!(!tokens_match("abc123", "abc124"));
assert!(!tokens_match("abc123", "abc12"));
assert!(!tokens_match("", "a"));
}
/// The preference starts the listener by default, off it doesn't, and a port given on the
/// command line or in the environment wins over it either way.
#[test]
fn the_preference_decides_unless_a_port_is_given() {
assert_eq!(plan(None, true), Some(Listen::Default));
assert_eq!(plan(None, false), None);
assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878)));
assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878)));
}
/// A taken port falls through to the next free one, on loopback only; a run of taken ports
/// is an error, not a hang.
#[test]
fn a_taken_port_falls_back_to_the_next_free_one() {
let first = bind_with_fallback(TEST.default_port, PORT_TRIES).unwrap();
let a = first.local_addr().unwrap();
assert!(a.ip().is_loopback() && (TEST.default_port..TEST.default_port + PORT_TRIES).contains(&a.port()), "{a}");
let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap();
assert!(second.local_addr().unwrap().port() > a.port());
assert!(bind_with_fallback(a.port(), 1).is_err());
}
#[test]
fn the_discovery_file_holds_a_port() {
assert_eq!(parse_port("27879\n"), Some(27879));
assert_eq!(parse_port("0"), None);
assert_eq!(parse_port("70000"), None);
assert_eq!(parse_port("nope"), None);
let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id()));
let path = dir.join("current.port");
TEST.write_token_file(&path, "27881").unwrap();
assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881));
let _ = std::fs::remove_dir_all(&dir);
}
/// A one-line token file (and one with no process file) still reads.
#[test]
fn a_token_file_reads_with_and_without_its_process_file() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-read-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("7879.token");
std::fs::write(&path, "legacy-token\n").unwrap();
let client = read_token_file(&path, TEST).unwrap();
assert_eq!(client.token, "legacy-token");
let src = client.file.as_ref().unwrap();
assert_eq!(src.path, std::fs::canonicalize(&path).unwrap());
assert!(src.modified.is_some() && src.writer.is_none());
std::fs::write(pid_path(&path), "not a pid\n").unwrap();
assert!(read_token_file(&path, TEST).unwrap().file.unwrap().writer.is_none());
std::fs::write(&path, "x".repeat(4097)).unwrap();
assert!(read_token_file(&path, TEST).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
/// A refused token's explanation names the app and writes the time as the app does.
#[test]
fn an_explanation_names_the_app() {
let file = Some(TokenSource { path: PathBuf::from("7878.token"), modified: Some(UNIX_EPOCH), writer: None });
let client = ClientToken { token: "secret-token".into(), file, app: TEST };
let error = client.explain_error("unauthorized");
assert!(error.contains("written t0 (") && error.contains("if TestCraft was force-quit"), "{error}");
assert!(!error.contains("secret-token"), "{error}");
let explicit = TEST.client_token(0, Some("explicit-token")).unwrap();
assert!(explicit.token == "explicit-token" && explicit.file.is_none());
assert_eq!(explicit.explain_error("unauthorized"), "unauthorized");
}
/// A process file naming a process that has exited marks the token file stale.
#[cfg(any(unix, windows))]
#[test]
fn a_token_file_from_an_exited_process_is_stale() {
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "auth::tests::tokens_compare_whole"])
.stdout(std::process::Stdio::null())
.spawn()
.unwrap();
let pid = child.id();
assert!(child.wait().unwrap().success());
let source = |writer| Some(TokenSource { path: PathBuf::from("7878.token"), modified: None, writer });
let stale = ClientToken { token: "secret-token".into(), file: source(Some((pid, None))), app: TEST };
let error = stale.explain_error("unauthorized");
assert!(error.contains(&format!("stale token file from an instance that exited (process {pid}): 7878.token")), "{error}");
assert!(!error.contains("secret-token"));
let live = ClientToken { token: "t".into(), file: source(Some((std::process::id(), process_start(std::process::id())))), app: TEST };
assert!(!live.explain_error("unauthorized").contains("stale"));
#[cfg(target_os = "linux")]
{
let reused = process_start(std::process::id()).map(|s| s + 1);
let reused = ClientToken { token: "t".into(), file: source(Some((std::process::id(), reused))), app: TEST };
assert!(reused.explain_error("unauthorized").contains("stale token file"));
}
}
/// The token file is the user's alone, and a client reads it back.
#[cfg(unix)]
#[test]
fn the_token_file_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("control/7878.token");
std::fs::create_dir_all(dir.join("control")).unwrap();
std::fs::write(&path, "stale").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
TEST.write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
let writer = read_token_file(&path, TEST).unwrap().file.unwrap().writer;
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
assert!(!path.exists() && !pid_path(&path).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
/// loopback) and write the same files. The one closing first leaves the other's token, process
/// and discovery files; the last one takes them all away, `current.pid` too.
#[test]
fn a_closing_launch_leaves_another_launchs_files() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
let (first, second) = ("a".repeat(64), "b".repeat(64));
// The first window, then the second on the same port, each writing both files.
for token in [&first, &second] {
TEST.write_token_file(&file, token).unwrap();
TEST.write_token_file(&current, "27878").unwrap();
}
// The first closes: the second's files stay.
remove_token_file(&file, &first);
remove_discovery(&current, &file, 27878, &first);
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
assert!(pid_path(&file).exists() && current.exists() && pid_path(&current).exists());
// Another port named in the discovery file is another app's too.
remove_discovery(&current, &file, 27879, &second);
assert!(current.exists());
// The second closes: nothing of either is left.
remove_token_file(&file, &format!(" {second}\n"));
remove_discovery(&current, &file, 27878, &second);
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(&current).exists());
// With no token file left (removed by hand, or never written), the discovery file of the
// port still goes; a missing file is no error.
TEST.write_token_file(&current, "27878").unwrap();
remove_token_file(&file, &second);
remove_discovery(&current, &file, 27878, &second);
assert!(!current.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+27
View File
@@ -0,0 +1,27 @@
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
//! #1618): the app's control server and a bridged client both say what the request still does when
//! it finishes. Which requests do what is the app's to say.
/// What a request still running does when it finishes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effect {
/// It only reads (the document, the window or the command list): nothing changes.
ReadsOnly,
/// It writes a file (an export, a save, a render to a path); the document stays as it is.
WritesFile,
/// It changes the document (or may: anything not known to read or only write a file).
Changes,
}
impl Effect {
/// What happens when the request finishes, for a message: "it only reads: nothing changes",
/// "it was not cancelled and will still write the file when it finishes; the design does not
/// change", … `document` is what the app calls its document ("design").
pub fn when_done(self, document: &str) -> String {
match self {
Effect::ReadsOnly => "it only reads: nothing changes".into(),
Effect::WritesFile => format!("it was not cancelled and will still write the file when it finishes; the {document} does not change"),
Effect::Changes => "it was not cancelled and will still apply when it finishes".into(),
}
}
}
+17
View File
@@ -0,0 +1,17 @@
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod auth;
mod effect;
mod request;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
pub use effect::Effect;
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
+118
View File
@@ -0,0 +1,118 @@
//! A control request as the window gets it, and the design watch subscribed connections wait on.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use serde_json::{Value, json};
pub type ControlResponse = Value;
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<ControlResponse>,
/// Set once the window starts on it; until then it waits its turn (`control.running`, #3654).
pub started: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
/// validity follows them through queues, computation and cursor animation.
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
}
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
pub fn refuse_cancelled_chat(&self) -> bool {
if chat_turn_cancelled(self.chat_turn.as_deref()) {
let _ = self.reply.send(cancelled_chat_response());
true
} else {
false
}
}
}
/// Whether the agent chat turn a request belongs to has ended (`None`: it belongs to none).
pub fn chat_turn_cancelled(turn: Option<&std::sync::atomic::AtomicBool>) -> bool {
turn.is_some_and(|active| !active.load(std::sync::atomic::Ordering::Relaxed))
}
/// The reply to a request whose agent chat turn has ended.
pub fn cancelled_chat_response() -> Value {
json!({"ok": false, "error": "not run: the agent chat turn ended or its document tab was left"})
}
/// The active design's id and revision for control connections that sent `control.subscribe`
/// (#6985): the window publishes them each frame while one is subscribed, and each such
/// connection waits for them to move, then sends the app's change event (`Protocol::change_event`).
#[derive(Default)]
pub struct DocumentWatch {
subscribers: AtomicUsize,
now: Mutex<Option<(String, u64)>>,
moved: Condvar,
}
/// The app's [`DocumentWatch`].
pub static DOCUMENT_WATCH: DocumentWatch = DocumentWatch::new();
impl DocumentWatch {
pub const fn new() -> Self {
DocumentWatch { subscribers: AtomicUsize::new(0), now: Mutex::new(None), moved: Condvar::new() }
}
fn now(&self) -> MutexGuard<'_, Option<(String, u64)>> {
self.now.lock().unwrap_or_else(PoisonError::into_inner)
}
/// The design as last published (`None` before anything was).
pub fn current(&self) -> Option<(String, u64)> {
self.now().clone()
}
/// The design as it is now; waiters wake only when it moved. Only the latest is kept, so
/// changes made faster than a connection sends them go out as one.
pub fn publish(&self, document_id: &str, revision: u64) {
let mut now = self.now();
if now.as_ref().is_some_and(|(id, r)| id == document_id && *r == revision) {
return;
}
*now = Some((document_id.to_string(), revision));
self.moved.notify_all();
}
/// Whether a connection is subscribed: the window publishes only then.
pub fn subscribed(&self) -> bool {
self.subscribers.load(Ordering::Relaxed) > 0
}
/// Count a subscribed connection until the returned guard is dropped.
pub fn join(&'static self) -> Subscriber {
self.subscribers.fetch_add(1, Ordering::Relaxed);
Subscriber(self)
}
/// Wait up to `wait` for the design to differ from `seen`, or for `stop`; the design then
/// (`None` before anything was published).
pub fn wait(&self, seen: &(String, u64), stop: &AtomicBool, wait: Duration) -> Option<(String, u64)> {
let unmoved = |now: &mut Option<(String, u64)>| now.as_ref() == Some(seen) && !stop.load(Ordering::Relaxed);
let (now, _) = self.moved.wait_timeout_while(self.now(), wait, unmoved).unwrap_or_else(PoisonError::into_inner);
now.clone()
}
}
/// A subscribed connection, counted by its [`DocumentWatch`] while this lives.
pub struct Subscriber(&'static DocumentWatch);
impl Drop for Subscriber {
fn drop(&mut self) {
self.0.subscribers.fetch_sub(1, Ordering::Relaxed);
// Under the lock, so a connection about to wait sees its `stop` set before it does.
let _now = self.0.now();
self.0.moved.notify_all();
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+1 -1
View File
@@ -154,7 +154,7 @@ impl VisualPreset {
match self.recipe {
Recipe::Opaque(r) => r.color,
// A UI swatch of the transmission values, not rendered glass or absorption.
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_color::srgb::encode8),
}
}
+27 -3
View File
@@ -3,7 +3,7 @@
use std::collections::BTreeMap;
use crate::persistent_name::{Base, FaceName, FeatureId};
use crate::persistent_name::{Base, EdgeName, FaceName, FeatureId};
use crate::{FeatureKind, PlaneRef};
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
@@ -18,9 +18,20 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
}
// Imported names belong to the complete canonical body prefix, even when that prefix
// happens to contain text resembling a feature token.
if let Some((body, suffix)) = name.rsplit_once(":face:")
&& let Some(new) = bodies.get(body)
let body_face = name.rsplit_once(":face:").and_then(|(body, suffix)| Some((bodies.get(body)?, body, suffix)));
// An edge name (`A|B`) is remapped one face at a time, so a body face on one side doesn't
// stop the other side's feature or body from being remapped (#13097). A body whose own
// name holds a `|` is a single face, not an edge.
if !body_face.is_some_and(|(_, body, _)| body.contains('|'))
&& let Ok(edge) = EdgeName::parse(name)
&& edge.faces.len() > 1
&& edge.to_string() == name
{
let faces: Vec<String> = edge.faces.iter().map(|face| remap_topology(&face.to_string(), ids, bodies)).collect();
let tie = edge.tie.map(|k| format!("~{k}")).unwrap_or_default();
return format!("{}{tie}", faces.join("|"));
}
if let Some((new, _, suffix)) = body_face {
return format!("{new}:face:{suffix}");
}
// A name that reads is remapped by its structure: every feature it names, and a body
@@ -410,6 +421,19 @@ mod clone_tests {
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
}
#[test]
fn an_edge_name_with_a_body_face_maps_every_face() {
// #13097: a body face on either side of an edge must not stop the other side's
// feature or body from being remapped.
let ids: BTreeMap<u64, u64> = [(2, 20)].into_iter().collect();
let bodies: BTreeMap<String, String> =
[("Plate".to_string(), "Plate (copy)".to_string()), ("A|B".to_string(), "A|B (copy)".to_string())].into_iter().collect();
assert_eq!(remap_topology("Plate:face:3|F2:top", &ids, &bodies), "Plate (copy):face:3|F20:top");
assert_eq!(remap_topology("F2:top|Plate:face:3", &ids, &bodies), "F20:top|Plate (copy):face:3");
assert_eq!(remap_topology("Plate:face:3|F2:top~1", &ids, &bodies), "Plate (copy):face:3|F20:top~1");
// A body whose name holds a `|` is still one face.
assert_eq!(remap_topology("A|B:face:0", &ids, &bodies), "A|B (copy):face:0");
}
#[test]
fn a_sheet_face_follows_its_body_and_its_flange() {
let ids = BTreeMap::from([(1, 2), (10, 20)]);
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
+6 -1
View File
@@ -176,7 +176,12 @@ mod tests {
let sag = mesh
.triangles
.iter()
.map(|t| t.map(|i| mesh.positions[i as usize]))
.zip(&mesh.tri_face)
// The side wall's own triangles: the flat caps have corners on the rim too.
.filter(|(_, f)| {
matches!(solidcraft_kernel::face_surface(&mb.body, **f as usize), Some(solidcraft_kernel::FaceSurface::Cylinder { .. }))
})
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
.fold(0.0, f64::max);
+20
View File
@@ -1831,6 +1831,26 @@ pub(super) fn line_beyond_reach(mb: &ModelBody, at: Vec3, dir: Vec3, reach: f64)
lo > hi
}
/// Whether [`kernel::cylinder_face_at`] is known to find no face of `mb` at `p`. It takes a face
/// only when its mesh of the body, made to a chord tolerance of `(size * 1e-3).max(1e-3)`, comes
/// within a twentieth of the body's size of the point. That mesh lies within its tolerance of
/// the exact faces, so of the body's exact box ([`exact_box`]): a box farther from the point
/// than a twentieth of the size and twice that tolerance holds no face for it. Working that out
/// from the exact box spares meshing every body in the design for each tap marking (#10227). A
/// mesh body, and a body whose box is unknown, are searched as before.
pub(super) fn beyond_cylinder_reach(mb: &ModelBody, p: Vec3) -> bool {
if mb.body.is_mesh() {
return false;
}
let Some(b) = exact_box(&mb.body) else { return false };
let size = mb.body.size();
let reach = size * 0.05 + 2.0 * (size * 1e-3).max(1e-3);
let off = |at: f64, lo: f64, hi: f64| (lo - at).max(at - hi).max(0.0);
let (dx, dy, dz) = (off(p.x, b.min.x, b.max.x), off(p.y, b.min.y, b.max.y), off(p.z, b.min.z, b.max.z));
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
dist.is_finite() && reach.is_finite() && dist > reach
}
/// Whether two boxes are known to be apart (an unknown box is never apart).
pub(super) fn boxes_apart(a: &Option<Aabb3>, b: &Option<Aabb3>) -> bool {
let (Some(a), Some(b)) = (a, b) else { return false };
+1
View File
@@ -192,6 +192,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
.bodies
.iter()
.enumerate()
.filter(|(_, b)| !super::more::beyond_cylinder_reach(b, p))
.filter_map(|(i, b)| {
#[cfg(test)]
super::cost_tests::count(|c| c.cylinder_search += 1);
+36 -8
View File
@@ -149,8 +149,14 @@ fn resolve_uncached(
LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => {
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
// A mesh body is one face with no edges: the flat patch of triangles picked,
// traced (#13464).
if target.body.is_mesh() {
crate::project3d::flat_outline(&mesh, q).iter().flat_map(|outline| fit_mesh_outline(plane, outline)).collect()
} else {
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
}
}
}
}
@@ -1308,8 +1314,8 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
}
/// A circle in space (centre, unit normal, radius) through the polyline's points, and whether
/// the polyline closes.
fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
/// the polyline closes. Its points lie on it within `within`, by default 1e-7 of the radius.
fn circle3(pts: &[Vec3], within: Option<f64>) -> Option<(Vec3, Vec3, f64, bool)> {
let n = pts.len();
if n < 4 {
return None;
@@ -1326,15 +1332,15 @@ fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) / (2.0 * l2);
let r = centre.dist(a);
let un = nrm.normalized()?;
let tol = 1e-7 * r.max(1.0);
let tol = within.unwrap_or(1e-7 * r.max(1.0));
pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - centre).dot(un).abs() < tol).then_some((centre, un, r, closed))
}
/// A circle seen at a slant is an ellipse, exactly: a full circle becomes an ellipse; an arc
/// becomes conics (rational quadratic Béziers of at most 90 degrees each — parallel projection
/// keeps their weights).
fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
let (c, n, r, closed) = circle3(pts)?;
fn tilted_circle(plane: &Plane, pts: &[Vec3], within: Option<f64>) -> Option<Vec<LinkGeom>> {
let (c, n, r, closed) = circle3(pts, within)?;
let sn = plane.normal();
let cosang = n.dot(sn).abs();
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
@@ -1429,9 +1435,31 @@ fn plane_trace(sk: &Plane, other: &Plane) -> Option<LinkGeom> {
Some(LinkGeom::Line(p0 - u * AXIS_HALF, p0 + u * AXIS_HALF))
}
/// Most corners a mesh face's outline has and is still taken for a polygon.
const MESH_POLYGON_CORNERS: usize = 12;
/// The outline of a mesh body's flat patch (a closed loop of its corners) as lines and arcs. Any
/// triangle's corners and any rectangle's lie on one circle, so only a loop of more corners than
/// [`MESH_POLYGON_CORNERS`], all on one circle, is a tessellated circle, an ellipse on a slanted
/// plane; and the sides of a polygon stay lines, never one fitted spline (#13464). A mesh file
/// keeps its corners in single precision, so they lie on the circle within about 1e-7 of their
/// size, not of the circle's.
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
let within = pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6;
if pts.len() > MESH_POLYGON_CORNERS + 1
&& let Some(e) = tilted_circle(plane, pts, Some(within))
{
return e;
}
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
let mut out = Vec::new();
fit_chain(&p2, None, &mut out);
out
}
/// Project a world polyline and fit lines and arcs to it.
fn fit_world(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
if let Some(e) = tilted_circle(plane, pts) {
if let Some(e) = tilted_circle(plane, pts, None) {
return e;
}
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
+128
View File
@@ -278,6 +278,134 @@ fn ray_hit(o: Vec3, d: Vec3, t: &[Vec3; 3]) -> Option<f64> {
Some(e2.dot(q) * inv)
}
/// Most triangles [`flat_outline`] looks through.
const FLAT_OUTLINE_TRIANGLES: usize = 2_000_000;
/// The outline of the flat patch of a mesh body's triangles at `at`: the triangle nearest `at`
/// and every triangle joined to it edge to edge on its plane, traced as closed loops, each with its
/// first point again at its end. An imported mesh is one face, so this flat patch is what a pick
/// on one of its faces means (#13464). Empty when the mesh has no triangle or is too large.
pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
use std::collections::{HashMap, HashSet, VecDeque};
if m.triangles.is_empty() || m.triangles.len() > FLAT_OUTLINE_TRIANGLES {
return Vec::new();
}
let size = m.bounds().diagonal().max(1e-9);
// Corners at one place are one corner: an STL repeats each corner for every triangle.
let weld = size * 1e-9;
let mut ids: HashMap<(i64, i64, i64), usize> = HashMap::new();
let mut corners: Vec<Vec3> = Vec::new();
let mut tri: Vec<[usize; 3]> = Vec::with_capacity(m.triangles.len());
for t in &m.triangles {
let Some(ps) = m.tri(t) else { continue };
let mut out = [0; 3];
for (o, p) in out.iter_mut().zip(ps) {
if !p.is_finite() {
return Vec::new();
}
let key = ((p.x / weld).round() as i64, (p.y / weld).round() as i64, (p.z / weld).round() as i64);
*o = *ids.entry(key).or_insert_with(|| {
corners.push(p);
corners.len() - 1
});
}
tri.push(out);
}
let at_of = |t: &[usize; 3]| t.map(|i| corners.get(i).copied().unwrap_or_default());
let normal = |t: &[usize; 3]| {
let [a, b, c] = at_of(t);
(b - a).cross(c - a).normalized()
};
// The triangle nearest the pick, and its plane.
let Some(seed) = (0..tri.len()).filter(|i| tri.get(*i).and_then(normal).is_some()).min_by(|x, y| {
let d = |i: &usize| {
tri.get(*i).map_or(f64::INFINITY, |t| {
let [a, b, c] = at_of(t);
crate::project::closest_on_triangle(at, a, b, c).dist(at)
})
};
d(x).total_cmp(&d(y))
}) else {
return Vec::new();
};
let Some((n0, p0)) = tri.get(seed).and_then(|t| Some((normal(t)?, at_of(t)[0]))) else { return Vec::new() };
let flat = |t: &[usize; 3]| normal(t).is_some_and(|n| n.dot(n0) > 1.0 - 1e-6) && at_of(t).iter().all(|p| (*p - p0).dot(n0).abs() <= size * 1e-6);
// Triangles by the edges they share.
let mut by_edge: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (i, t) in tri.iter().enumerate() {
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
by_edge.entry((a.min(b), a.max(b))).or_default().push(i);
}
}
// The flat patch: from the seed across shared edges onto triangles on its plane.
let mut patch: HashSet<usize> = HashSet::from([seed]);
let mut queue = VecDeque::from([seed]);
while let Some(i) = queue.pop_front() {
let Some(t) = tri.get(i) else { continue };
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
for j in by_edge.get(&(a.min(b), a.max(b))).into_iter().flatten() {
if !patch.contains(j) && tri.get(*j).is_some_and(flat) {
patch.insert(*j);
queue.push_back(*j);
}
}
}
}
// Its outline: the edges only one of its triangles has, each the way its triangle runs.
let mut next: HashMap<usize, Vec<usize>> = HashMap::new();
for i in &patch {
let Some(t) = tri.get(*i) else { continue };
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
let inside = by_edge.get(&(a.min(b), a.max(b))).map_or(0, |ts| ts.iter().filter(|j| patch.contains(j)).count());
if inside == 1 {
next.entry(a).or_default().push(b);
}
}
}
let mut loops = Vec::new();
let mut starts: Vec<usize> = next.keys().copied().collect();
starts.sort_unstable();
for start in starts {
while let Some(mut v) = next.get_mut(&start).and_then(Vec::pop) {
let mut pts: Vec<Vec3> = [start, v].iter().filter_map(|i| corners.get(*i).copied()).collect();
let mut steps = 0;
while v != start && steps < patch.len() * 3 {
let Some(w) = next.get_mut(&v).and_then(Vec::pop) else { break };
pts.extend(corners.get(w).copied());
v = w;
steps += 1;
}
if v == start && pts.len() >= 4 {
loops.extend(turning_corners(&pts, size * 1e-9));
}
}
}
loops
}
/// A closed loop (its first point again at its end) without the corners where it runs straight
/// on: a mesh's outline often has a corner part way along a side, where a triangle of the patch
/// meets it. None when fewer than three corners turn.
fn turning_corners(pts: &[Vec3], tol: f64) -> Option<Vec<Vec3>> {
let ring = pts.get(..pts.len().saturating_sub(1))?;
let n = ring.len();
let turns = |i: usize| {
let (prev, here, next) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n));
let (Some(prev), Some(here), Some(next)) = (prev, here, next) else { return false };
let (a, b) = (*here - *prev, *next - *here);
a.cross(b).len() > tol * a.len().max(b.len()).max(1.0)
};
let mut out: Vec<Vec3> = (0..n).filter(|i| turns(*i)).filter_map(|i| ring.get(i).copied()).collect();
if out.len() < 3 {
return None;
}
out.extend(out.first().copied());
Some(out)
}
/// Project a world polyline onto face `f` along `dir` (either way, nearest hit). Points that
/// miss the face split the result.
pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec<Vec<Vec3>> {
+2
View File
@@ -36,6 +36,8 @@ required-features = ["solver-profiling"]
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-control = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+93 -28
View File
@@ -12,10 +12,11 @@ use solidcraft_geom::prelude::*;
use super::{CommandSpec, not_in_sketch};
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
use crate::schema::features::SweepParams;
use crate::schema::features::{
AppliedRadius, BoundingSolidParams, BoxParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice,
FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams,
TorusParams,
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
SphereParams, TorusParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -27,12 +28,14 @@ const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
const CONE_CONTRACT: TypedContract<ConeParams> = TypedContract::new();
const PIPE_CONTRACT: TypedContract<PipeParams> = TypedContract::new();
const SWEEP_CONTRACT: TypedContract<SweepParams> = TypedContract::new();
const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContract::new();
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
const COMBINE_CONTRACT: TypedContract<CombineParams> = TypedContract::new();
const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::new();
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
const CHAMFER_CONTRACT: TypedContract<ChamferParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
@@ -46,9 +49,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
.at("SOLID", "CREATE")
.icon("sweep")
.contract(&SWEEP_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, touching?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"),
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
@@ -88,7 +93,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&FILLET_CONTRACT)
.output(output_schema_for::<FilletResult>)
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them); type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
CommandSpec::new("solid.chamfer", "Chamfer", crate::checked!("solid.chamfer", chamfer))
.at("SOLID", "MODIFY")
.icon("chamfer")
.contract(&CHAMFER_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
.at("SOLID", "MODIFY")
.icon("combine")
@@ -112,7 +122,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
.at("SOLID", "CREATE")
.icon("pipe")
@@ -2941,7 +2951,7 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
const CLEARANCE_FITS: [&str; 6] = ["close", "fine", "normal", "medium", "loose", "coarse"];
/// A value typed by the user, cut short for a message.
fn brief(text: &str) -> String {
pub(super) fn brief(text: &str) -> String {
const MAX: usize = 32;
match text.char_indices().nth(MAX) {
Some((end, _)) => format!("{}…", &text[..end]),
@@ -2985,6 +2995,35 @@ fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
}
}
/// The counterbore a cap screw's head sits in (`head` with `clearance`): its diameter and depth.
/// The head's dk and k come from the standard's table (ISO 4762 socket heads, ISO 7380-1 button
/// heads, in `parts`), with `head_gap` (default 0.5 mm) added each side and below. A gap that
/// names parameters is kept as an expression, so the recess follows them. `None` without `head`.
fn head_recess(s: &Session, cmd: &str, p: &Value) -> Result<Option<(Expr, Expr)>> {
let head = match p.get("head") {
None | Some(Value::Null) => return Ok(None),
Some(Value::String(h)) => h.trim(),
Some(_) => return Err(bad(cmd, "`head` must be a text: `ISO 4762` (socket head) or `ISO 7380-1` (button head)")),
};
let size = str_(p, "clearance").ok_or_else(|| bad(cmd, "`head` needs `clearance`, the screw size the head belongs to (e.g. \"M4\")"))?;
let (dk, k) = super::parts::cap_screw_head(head, size).map_err(|e| bad(cmd, e))?;
let gap = match (expr(p, "head_gap"), p.get("head_gap")) {
(Some(g), _) => g,
(None, None | Some(Value::Null)) => Expr::new("0.5 mm"),
(None, v) => return Err(bad(cmd, format!("`head_gap` {}", expr_problem(v)))),
};
let g = s.doc.eval(gap.as_str(), Kind::Length).map_err(|e| bad(cmd, format!("head_gap: {e}")))?;
if !(0.0..=dk).contains(&g) {
return Err(bad(cmd, format!("`head_gap` must be 0 to {dk} mm (the head's diameter), not {g}")));
}
let mm = |x: f64| format!("{} mm", (x * 1e6).round() / 1e6);
Ok(Some(if solidcraft_doc::expr::references(gap.as_str()).is_empty() {
(Expr::new(mm(dk + 2.0 * g)), Expr::new(mm(k + g)))
} else {
(Expr::new(format!("{dk} mm + 2 * ({})", gap.as_str())), Expr::new(format!("{k} mm + ({})", gap.as_str())))
}))
}
/// The pitches to try for a plain `M<d>` that has no ISO coarse pitch, `None` for anything else.
fn fine_only_hint(t: &str) -> Option<String> {
let diameter = t.trim().strip_prefix(['M', 'm']).and_then(|d| d.trim().parse::<f64>().ok());
@@ -3208,18 +3247,28 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
(None, None | Some(Value::Null)) => None,
(None, v) => return Err(bad(cmd, format!("`bridge` {}", expr_problem(v)))),
};
let kind = match str_(p, "type").unwrap_or("simple") {
// A counterbore sized to the screw's head: `head` with `clearance`.
let head = head_recess(s, cmd, p)?;
if head.is_some() && str_(p, "type").is_some_and(|t| t != "counterbore") {
return Err(bad(cmd, "`head` needs `type: counterbore` (the default with `head`); a countersink for a flat head is not supported yet"));
}
let kind = match str_(p, "type").unwrap_or(if head.is_some() { "counterbore" } else { "simple" }) {
"simple" => match expr(p, "tip_angle") {
Some(t) if depth.is_some() => solidcraft_doc::HoleKind::Drilled { tip_angle: t },
_ => solidcraft_doc::HoleKind::Simple,
},
"drilled" => solidcraft_doc::HoleKind::Drilled { tip_angle: expr(p, "tip_angle").unwrap_or_else(|| "118 deg".into()) },
"counterbore" => solidcraft_doc::HoleKind::Counterbore {
cb_diameter: get("cb_diameter")?,
cb_depth: get("cb_depth")?,
tip_angle: expr(p, "tip_angle"),
bridge: bridge.clone(),
},
"counterbore" => {
// Sized to the screw's head unless given; a value given, even a bad one, is checked.
let given = |k: &str| !matches!(p.get(k), None | Some(Value::Null));
let (cb_diameter, cb_depth) = match &head {
Some((d, h)) => {
(if given("cb_diameter") { get("cb_diameter")? } else { d.clone() }, if given("cb_depth") { get("cb_depth")? } else { h.clone() })
}
None => (get("cb_diameter")?, get("cb_depth")?),
};
solidcraft_doc::HoleKind::Counterbore { cb_diameter, cb_depth, tip_angle: expr(p, "tip_angle"), bridge: bridge.clone() }
}
"countersink" => solidcraft_doc::HoleKind::Countersink {
cs_diameter: get("cs_diameter")?,
// The default only when no angle is given: a supplied one out of range is refused (#9625).
@@ -3516,17 +3565,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
let orientation = match p.get("orientation") {
None | Some(Value::Null) => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
Some(v) => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
if !(d.len() > 1e-9) {
return Err(bad(cmd, "the `orientation` direction must not be zero"));
}
SweepOrientation::Direction(Dir(d))
}
Some(v) => sweep_orientation(v).map_err(|e| bad(cmd, e))?,
};
let twist = match p.get("twist") {
None | Some(Value::Null) => None,
@@ -3554,6 +3593,24 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO
Ok((orientation, twist, scale))
}
/// A sweep's `orientation` as given (not `null`): perpendicular or parallel (any case), or a
/// non-zero direction `[x, y, z]`, `{x, y, z}` or `{direction: …}`. The error is the message.
pub(crate) fn sweep_orientation(v: &Value) -> std::result::Result<SweepOrientation, &'static str> {
match v {
Value::String(o) if o.eq_ignore_ascii_case("perpendicular") => Ok(SweepOrientation::Perpendicular),
Value::String(o) if o.eq_ignore_ascii_case("parallel") => Ok(SweepOrientation::Parallel),
v => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or("`orientation` must be perpendicular, parallel or a direction [x, y, z]")?;
if !(d.len() > 1e-9) {
return Err("the `orientation` direction must not be zero");
}
Ok(SweepOrientation::Direction(Dir(d)))
}
}
}
/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two
/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or
/// stretch; and no orientation, twist or scale besides, since the rails set those.
@@ -3574,9 +3631,7 @@ pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -
pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default_sketch: Option<u64>) -> Result<(Vec<SweepRail>, RailFit)> {
let rail_fit = match p.get("rail_fit") {
None | Some(Value::Null) => RailFit::Scale,
Some(Value::String(f)) if f.eq_ignore_ascii_case("scale") => RailFit::Scale,
Some(Value::String(f)) if f.eq_ignore_ascii_case("stretch") => RailFit::Stretch,
Some(_) => return Err(bad(cmd, "`rail_fit` must be scale or stretch")),
Some(v) => rail_fit_form(v).map_err(|e| bad(cmd, e))?,
};
let given = match p.get("rails") {
None | Some(Value::Null) => return Ok((Vec::new(), rail_fit)),
@@ -3602,6 +3657,16 @@ pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default
Ok((rails, rail_fit))
}
/// Guide rails' `rail_fit` as given (not `null`): scale or stretch, in any case. The error is
/// the message.
pub(crate) fn rail_fit_form(v: &Value) -> std::result::Result<RailFit, &'static str> {
match v {
Value::String(f) if f.eq_ignore_ascii_case("scale") => Ok(RailFit::Scale),
Value::String(f) if f.eq_ignore_ascii_case("stretch") => Ok(RailFit::Stretch),
_ => Err("`rail_fit` must be scale or stretch"),
}
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
+26 -11
View File
@@ -23,6 +23,8 @@ use solidcraft_io::Format;
use super::CommandSpec;
use super::print_face;
use crate::params::{bad, bool_, num, str_, string_list};
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
@@ -40,15 +42,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
CommandSpec::new("file.save", "Save", save)
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
.icon("save")
.key("Ctrl+S")
.noundo()
.contract(&SAVE_CONTRACT)
.output(output_schema_for::<SaveResult>)
.params("path? (default: current file; a name that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (replace the design's file even though another program or computer changed it since it was opened or saved; without it that save is refused)"),
CommandSpec::new("file.save_as", "Save As", save_as)
CommandSpec::new("file.save_as", "Save As", crate::checked!("file.save_as", save_as))
.icon("save")
.key("Ctrl+Shift+S")
.noundo()
.contract(&SAVE_AS_CONTRACT)
.output(output_schema_for::<SaveResult>)
.params("path (a design file name, .sc as a rule; one that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (as file.save, when `path` is the design's own file)"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore")
.noundo()
@@ -122,6 +128,8 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"name": name}))
}
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
@@ -682,7 +690,15 @@ fn write_bytes(
s.disk = solidcraft_io::design_lock::stamp(path);
s.read_only = false;
s.mark_saved();
let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name});
let mut out = SaveResult {
path: path.to_string(),
bytes: bytes.len(),
name: s.doc.name.clone(),
kept_format: None,
warnings: None,
not_kept: None,
param_warnings: Vec::new(),
};
// A newer SolidCraft's file keeps what this version can't read, and its format number. What
// couldn't go back, because the design was changed around it, is said.
if s.doc.newer_file.is_some() {
@@ -690,13 +706,13 @@ fn write_bytes(
if format == "json" && s.doc.newer_file.as_ref().is_some_and(|file| file.needs_container()) {
lost.push("the parts of the file beside the design, which plain JSON has no room for: save as .sc to keep them".to_string());
}
out["kept_format"] = json!(s.doc.format);
out.kept_format = Some(s.doc.format.clone());
if !lost.is_empty() {
out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::<Vec<_>>());
out["not_kept"] = json!(lost);
out.warnings = Some(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect());
out.not_kept = Some(lost);
}
}
Ok(out)
serde_json::to_value(out).map_err(|e| bad("file.save", e.to_string()))
}
impl Session {
@@ -1394,12 +1410,11 @@ mod tests {
assert!(bytes.starts_with(signature), "{cmd}");
}
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
// bytes each. A planar face is exact for any triangulation of its outline, so the mesher
// gives each of the box's six faces two triangles (kernel test `a_box_is_two_triangles_a_face`).
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 2);
let triangles = 6 * 2;
let stl = std::fs::read(at("box.stl")).unwrap();
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
assert_eq!(stl.len(), 84 + 50 * triangles);
+8 -1
View File
@@ -8,14 +8,21 @@ use super::{
CommandSpec,
features::{add_feature, check_expr},
};
use crate::schema::features::FeatureResult;
use crate::schema::mesh::MeshConvertParams;
use crate::schema::{TypedContract, output_schema_for};
use crate::{
Result, Sel, Session,
params::{bad, expr, str_},
};
const MESH_CONVERT_CONTRACT: TypedContract<MeshConvertParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("mesh.convert", "Convert Mesh", convert)
CommandSpec::new("mesh.convert", "Convert Mesh", crate::checked!("mesh.convert", convert))
.icon("import")
.contract(&MESH_CONVERT_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body?: mesh body name (default: the selected mesh body); tolerance?: length expression (default 0.05 mm): reconstruct a closed constant-section mechanical mesh as editable planar and cylindrical faces, with a parametric timeline feature in the mesh body's owning component (the active component must be that component or an ancestor; otherwise activate the named component first); the active component is unchanged; name?"),
];
+2 -1
View File
@@ -45,6 +45,7 @@ pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub(crate) use features::{rail_fit_form, sweep_orientation};
pub use file::open_steps::OpenSteps;
pub(crate) use inspect::color_param;
pub use join_pieces::{Addition, addition};
@@ -131,7 +132,7 @@ mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
pub(crate) use sketch_project::{RefForm, ref_form};
pub(crate) use sketch_project::{MAX_PROJECTED_ENTITIES, RefForm, model_ref, ref_form};
mod sketch_store;
mod surface;
mod surface_analysis;
+14 -3
View File
@@ -159,12 +159,23 @@ pub static COMMANDS: &[CommandSpec] = &[
"sketch: id|name; joint? with from and to, or study?; index?; samples? (2…500); auto_update?: false to detach. \
Edits a saved associative trace's driving conditions in one undo step",
),
CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).icon("graph").noundo().params(analysis::PLOT_DOC),
CommandSpec::new("motion.interference", "Motion Interference", analysis::interference)
CommandSpec::new("motion.plot", "Plot Motion Study", crate::checked!("motion.plot", analysis::plot))
.contract(&TypedContract::<crate::schema::motion_analysis::PlotParams>::new())
.output(output_schema_for::<crate::schema::motion_analysis::MotionPlot>)
.icon("graph")
.noundo()
.params(analysis::PLOT_DOC),
CommandSpec::new("motion.interference", "Motion Interference", crate::checked!("motion.interference", analysis::interference))
.contract(&TypedContract::<crate::schema::motion_analysis::InterferenceParams>::new())
.output(output_schema_for::<crate::schema::motion_analysis::MotionInterference>)
.icon("interference")
.noundo()
.params(analysis::INTERFERENCE_DOC),
CommandSpec::new("joint.sweep", "Sweep Joint", sweep::sweep).icon("interference").params(sweep::SWEEP_DOC),
CommandSpec::new("joint.sweep", "Sweep Joint", crate::checked!("joint.sweep", sweep::sweep))
.icon("interference")
.contract(&TypedContract::<crate::schema::joint_sweep::SweepParams>::new())
.output(output_schema_for::<crate::schema::joint_sweep::SweepResult>)
.params(sweep::SWEEP_DOC),
CommandSpec::new("joint.find_limits", "Find Joint Limits", crate::checked!("joint.find_limits", sweep::find_limits))
.icon("interference")
.noundo()
+84 -40
View File
@@ -6,7 +6,7 @@
use solidcraft_geom::prelude::*;
use std::collections::BTreeMap;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::joints;
use solidcraft_doc::motion::MotionStudy;
@@ -16,6 +16,7 @@ use solidcraft_geom::Aabb3;
use super::contact_sweep::{ContactGeometry, Member};
use super::{drive_with_contact, joint_id, keyed, occurrences, path_world, study_index, traced_point, values_at};
use crate::params::{bad, bool_, num};
use crate::schema::motion_analysis::{CollidingPair, MotionInterference, MotionPlot, PlotSeries, PlottedPoint, StudyStop};
use crate::{EngineError, Result, Session};
pub const PLOT_DOC: &str = "study: id|name; joints?: [id|name] (default: every joint that moves); occurrence? and point? (a point \
@@ -78,8 +79,8 @@ fn step_of(m: &MotionStudy, k: usize, n: usize) -> f64 {
/// Plays study `m` on a copy of the design in `n` samples, each driven from the one before (so
/// closed loops follow on their branch, as `motion.play` places them; contact sets stop it only
/// when `contact`). `each` sees the copy at every sample and answers whether to go on. Returns
/// where the motion stopped short (all null when it did not).
fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl FnMut(&Session, usize) -> Result<bool>) -> Result<Value> {
/// where the motion stopped short (all `None` when it did not).
fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl FnMut(&Session, usize) -> Result<bool>) -> Result<Stopped> {
let mut t = s.scratch();
// Played from where the parts are placed: moves only shown are not where joints are solved.
t.pending_moves.clear();
@@ -91,14 +92,22 @@ fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl Fn
let want = values_at(&t, m, step_of(m, k, n));
let (r, _) = drive_with_contact(&mut t, &want, &given)?;
let go = each(&t, k)?;
if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() {
return Ok(json!({"stopped_at": k, "stopped_by_loop": r["stopped_by_loop"], "stopped_by_contact": r["stopped_by_contact"]}));
let (by_loop, by_contact) = (r["stopped_by_loop"].as_object(), r["stopped_by_contact"].as_object());
if by_loop.is_some() || by_contact.is_some() {
return Ok(Stopped { stop: StudyStop { stopped_at: Some(k), stopped_by_loop: by_loop.cloned() }, by_contact: by_contact.cloned() });
}
if !go {
break;
}
}
Ok(json!({"stopped_at": null, "stopped_by_loop": null, "stopped_by_contact": null}))
Ok(Stopped { stop: StudyStop { stopped_at: None, stopped_by_loop: None }, by_contact: None })
}
/// Where [`play`] stopped short: the replies' `stopped_at` and `stopped_by_loop`, and the
/// contact that stopped it (which only a plot reports: interference plays without contact).
struct Stopped {
stop: StudyStop,
by_contact: Option<Map<String, Value>>,
}
/// The derivative of samples `v` spaced `h` apart: central differences inside, second-order
@@ -206,12 +215,9 @@ pub(super) fn plot(s: &mut Session, p: &Value) -> Result<Value> {
let seconds_per_step = duration.map(|d| d / f64::from(m.steps));
let h = f64::from(m.steps) / (n - 1) as f64 * seconds_per_step.unwrap_or(1.0);
let per = if duration.is_some() { "s" } else { "step" };
let steps: Vec<f64> = (0..taken).map(|k| step_of(&m, k, n)).collect();
let mut out = json!({"study": m.name, "steps": m.steps, "samples": taken, "step": steps});
if let Some(sps) = seconds_per_step {
out["time"] = steps.iter().map(|st| st * sps).collect();
}
let joints_out: Vec<Value> = series
let step: Vec<f64> = (0..taken).map(|k| step_of(&m, k, n)).collect();
let time = seconds_per_step.map(|sps| step.iter().map(|st| st * sps).collect());
let joints_out: Vec<PlotSeries> = series
.iter()
.zip(values.iter_mut())
.map(|((id, i), v)| {
@@ -227,13 +233,21 @@ pub(super) fn plot(s: &mut Session, p: &Value) -> Result<Value> {
unwrap_degrees(v);
}
}
json!({"joint": id, "name": j.map(|j| j.name.clone()), "index": i, "dof": j.and_then(|j| j.kind.dofs().get(*i).copied()),
"unit": unit, "velocity_unit": format!("{unit}/{per}"), "acceleration_unit": format!("{unit}/{per}²"),
"value": v, "velocity": derivative(v, h), "acceleration": second_derivative(v, h)})
PlotSeries {
joint: *id,
name: j.map(|j| j.name.clone()),
index: *i,
dof: j.and_then(|j| j.kind.dofs().get(*i).copied()),
unit,
velocity_unit: format!("{unit}/{per}"),
acceleration_unit: format!("{unit}/{per}²"),
velocity: derivative(v, h),
acceleration: second_derivative(v, h),
value: std::mem::take(v),
}
})
.collect();
out["joints"] = json!(joints_out);
if let Some((occ, _)) = &point {
let followed = point.as_ref().map(|(occ, _)| {
let axis = |c: usize| -> Vec<f64> { path.iter().map(|q| [q.x, q.y, q.z].get(c).copied().unwrap_or(0.0)).collect() };
let d: Vec<(Vec<f64>, Vec<f64>)> = (0..3).map(|c| (derivative(&axis(c), h), second_derivative(&axis(c), h))).collect();
let vec_at = |k: usize, second: bool| -> Vec3 {
@@ -241,16 +255,33 @@ pub(super) fn plot(s: &mut Session, p: &Value) -> Result<Value> {
Vec3::new(c(0), c(1), c(2))
};
let (vel, acc): (Vec<Vec3>, Vec<Vec3>) = (0..path.len()).map(|k| (vec_at(k, false), vec_at(k, true))).unzip();
let xyz = |list: &[Vec3]| -> Value { list.iter().map(|v| json!([v.x, v.y, v.z])).collect() };
let xyz = |list: &[Vec3]| -> Vec<[f64; 3]> { list.iter().map(|v| [v.x, v.y, v.z]).collect() };
let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == occ.last()).map_or_else(|| "root".to_string(), |o| o.name.clone());
out["point"] = json!({"occurrence": name, "unit": "mm", "velocity_unit": format!("mm/{per}"), "acceleration_unit": format!("mm/{per}²"),
"position": xyz(&path), "velocity": xyz(&vel), "acceleration": xyz(&acc),
"speed": vel.iter().map(|v| v.len()).collect::<Vec<_>>(), "acceleration_norm": acc.iter().map(|v| v.len()).collect::<Vec<_>>()});
}
for k in ["stopped_at", "stopped_by_loop", "stopped_by_contact"] {
out[k] = stop[k].clone();
}
Ok(out)
PlottedPoint {
occurrence: name,
unit: "mm",
velocity_unit: format!("mm/{per}"),
acceleration_unit: format!("mm/{per}²"),
position: xyz(&path),
velocity: xyz(&vel),
acceleration: xyz(&acc),
speed: vel.iter().map(|v| v.len()).collect(),
acceleration_norm: acc.iter().map(|v| v.len()).collect(),
}
});
let reply = MotionPlot {
study: m.name,
steps: m.steps,
samples: taken,
step,
time,
joints: joints_out,
point: followed,
stop: stop.stop,
stopped_by_contact: stop.by_contact,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// One pair of parts that collides along a study.
@@ -466,7 +497,7 @@ fn interference_within(s: &mut Session, p: &Value, budget: usize, seconds: Optio
}
let mut list: Vec<(&(u64, u64), &Collision)> = found.iter().collect();
list.sort_by_key(|(_, c)| c.samples.first().copied().unwrap_or(usize::MAX));
let collisions: Vec<Value> = list
let collisions: Vec<CollidingPair> = list
.iter()
.map(|(_, c)| {
let (volume, deepest, not_measured) = match &c.most_volume {
@@ -475,23 +506,36 @@ fn interference_within(s: &mut Session, p: &Value, budget: usize, seconds: Optio
};
let first = c.samples.first().map(|k| step_of(&m, *k, n));
let last = c.samples.last().map(|k| step_of(&m, *k, n));
json!({"a": c.names.0, "b": c.names.1, "first_step": first, "last_step": last, "steps": ranges(&m, &c.samples, n),
"depth_mm": c.depth, "deepest_step": deepest, "volume_mm3": volume, "not_measured": not_measured})
CollidingPair {
a: c.names.0.clone(),
b: c.names.1.clone(),
first_step: first,
last_step: last,
steps: ranges(&m, &c.samples, n),
depth_mm: c.depth,
deepest_step: deepest,
volume_mm3: volume,
not_measured,
}
})
.collect();
// A check cut short says nothing about the steps it did not reach, so it is not clear (#7855).
let clear = collisions.is_empty() && !truncated;
let mut out = json!({"study": m.name, "steps": m.steps, "clear": clear, "collisions": collisions,
"pairs": geometry.pair_count(), "samples": checked, "truncated": truncated});
if truncated {
out["checked_to"] = json!(checked.checked_sub(1).map(|k| step_of(&m, k, n)));
}
if let Some(v) = seconds {
out["budget_s"] = json!(v);
}
out["stopped_at"] = stop["stopped_at"].clone();
out["stopped_by_loop"] = stop["stopped_by_loop"].clone();
Ok(out)
let reply = MotionInterference {
study: m.name.clone(),
steps: m.steps,
clear,
collisions,
pairs: geometry.pair_count(),
samples: checked,
truncated,
// At least one sample was checked (none is refused above), so this is a step.
checked_to: if truncated { checked.checked_sub(1).map(|k| step_of(&m, k, n)) } else { None },
budget_s: seconds,
stop: stop.stop,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
#[cfg(test)]
+32 -26
View File
@@ -8,7 +8,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_doc::Document;
use solidcraft_doc::joints::JointKind;
@@ -17,6 +17,7 @@ use super::analysis::checked_pairs;
use super::contact_sweep::{ContactGeometry, Gap};
use super::{drive_with_contact, joint_id, value};
use crate::params::{bad, bool_};
use crate::schema::joint_sweep::{NearestGap, SampleGap, SweepResult, SweepSample};
use crate::schema::motion::{FindLimitsResult, FoundLimits, Overlap, Stop, Why};
use crate::{EngineError, Result, Session};
@@ -87,7 +88,7 @@ struct Swept {
/// The last value clear of contact (or where a loop or limit stopped it), and what stopped
/// it; `None`: it reached `to`.
stop: Option<(f64, Stop)>,
samples: Vec<Value>,
samples: Vec<SweepSample>,
nearest: BTreeMap<String, Nearest>,
touching: Vec<[String; 2]>,
/// Pairs of parts passing into each other at the start deeper than a touch: (a, b, mm).
@@ -168,24 +169,28 @@ pub(super) fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
doc.assembly.contact_enabled = s.doc.assembly.contact_enabled;
*s.doc_mut() = doc;
}
let by = |stop: &Stop| serde_json::to_value(stop).map_err(|e| EngineError::Other(e.to_string()));
let mut out = json!({
"joint": id, "name": name, "index": index, "unit": if angle { "deg" } else { "mm" },
"from": shown(from), "to": shown(to),
"stopped_at": r.stop.as_ref().map(|(v, _)| shown(*v)),
"stopped_by": r.stop.as_ref().map(|(_, stop)| by(stop)).transpose()?,
"samples": r.samples,
"refine_mm": refine,
"gap_method": "mesh", "gap_tolerance_mm": r.chord,
"pairs": r.pairs,
"touching_at_start": r.touching,
"applied": applied,
});
if report_gaps {
out["min_gap"] =
r.nearest.into_iter().map(|(k, g)| (k, json!({"mm": g.mm, "at_value": g.at, "to": g.to}))).collect::<serde_json::Map<_, _>>().into();
}
Ok(out)
let (stopped_at, stopped_by) = r.stop.map(|(v, stop)| (shown(v), stop)).unzip();
let min_gap = report_gaps.then(|| r.nearest.into_iter().map(|(part, g)| (part, NearestGap { mm: g.mm, at_value: g.at, to: g.to })).collect());
let reply = SweepResult {
joint: id,
name,
index,
unit: if angle { "deg" } else { "mm" },
from: shown(from),
to: shown(to),
stopped_at,
stopped_by,
samples: r.samples,
refine_mm: refine,
gap_method: "mesh",
gap_tolerance_mm: r.chord,
pairs: r.pairs,
touching_at_start: r.touching,
applied,
min_gap,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
pub(super) fn find_limits(s: &mut Session, p: &Value) -> Result<Value> {
@@ -372,18 +377,19 @@ fn run(s: &Session, mut geometry: ContactGeometry, sw: Sweep, cmd: &str) -> Resu
let targets: Vec<f64> =
(1..=n).map(|k| from + (sample_to - from) * k as f64 / n as f64).chain(((sample_to - to).abs() > 1e-9).then_some(to)).collect();
let mut samples: Vec<Value> = Vec::new();
let mut samples: Vec<SweepSample> = Vec::new();
let mut nearest: BTreeMap<String, Nearest> = BTreeMap::new();
let mut record = |t: &Session, v: f64, work: &mut usize, geometry: &ContactGeometry| -> Result<()> {
if !report_gaps {
samples.push(json!({"value": shown(v)}));
samples.push(SweepSample { value: shown(v), gap: None });
return Ok(());
}
let gaps = geometry.gaps(t, f64::INFINITY, work, budget)?;
match gaps.first() {
Some(g) => samples.push(json!({"value": shown(v), "min_gap_mm": g.mm, "pair": [g.a.1, g.b.1]})),
None => samples.push(json!({"value": shown(v), "min_gap_mm": null, "pair": null})),
}
let gap = match gaps.first() {
Some(g) => SampleGap { min_gap_mm: Some(g.mm), pair: Some([g.a.1.clone(), g.b.1.clone()]) },
None => SampleGap { min_gap_mm: None, pair: None },
};
samples.push(SweepSample { value: shown(v), gap: Some(gap) });
for g in &gaps {
for (me, other) in [(&g.a.1, &g.b.1), (&g.b.1, &g.a.1)] {
if nearest.get(me).is_none_or(|x| g.mm < x.mm) {
+21
View File
@@ -343,6 +343,27 @@ fn metric(size: &str) -> Option<(f64, f64)> {
METRIC.iter().find(|(s, _, _)| s.eq_ignore_ascii_case(size)).map(|(_, d, p)| (*d, *p))
}
/// The head a counterbore seats, for `solid.hole`'s `head`: (head diameter dk, head height k)
/// of a metric cap screw, by its standard (`ISO 4762`, `ISO 7380-1`) or the family name
/// `parts.insert` takes. The size is written as `clearance` takes it (`M4`, or `M4x0.7` with the
/// pitch). The error names what is missing: the standard, or the size in it.
pub(crate) fn cap_screw_head(standard: &str, size: &str) -> std::result::Result<(f64, f64), String> {
let (name, table): (&str, &[(&str, f64, f64, f64, f64)]) = match standard.trim() {
s if s.eq_ignore_ascii_case("ISO 4762") || s.eq_ignore_ascii_case("socket_head_cap_screw") => ("ISO 4762", &SHCS),
s if s.eq_ignore_ascii_case("ISO 7380-1") || s.eq_ignore_ascii_case("button_head_cap_screw") => ("ISO 7380-1", &BHCS),
other => {
return Err(format!("no head standard `{}`: ISO 4762 (socket head) or ISO 7380-1 (button head)", super::features::brief(other)));
}
};
let size = size.trim().split(['x', 'X']).next().unwrap_or("").trim();
let (first, last) = (table.first().map_or("", |r| r.0), table.last().map_or("", |r| r.0));
table
.iter()
.find(|r| r.0.eq_ignore_ascii_case(size))
.map(|r| (r.1, r.2))
.ok_or_else(|| format!("no {name} head for `{}`: {first} to {last}", super::features::brief(size)))
}
fn default_length(d: f64) -> f64 {
LENGTHS.iter().copied().find(|l| *l >= 2.5 * d).unwrap_or(100.0)
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1721,7 +1721,7 @@ fn cpu_saved_thin_sheet_uses_its_authored_ior_beer_thickness_and_roundtrips_offl
let a = (-sigma * 2.0).exp();
let denominator = 1.0 - f * f * a * a;
let scene = f + (1.0 - f).powi(2) * (f * a * a + a) / denominator;
solidcraft_geom::srgb::encode8(scene / (1.0 + scene))
solidcraft_color::srgb::encode8(scene / (1.0 + scene))
});
assert_eq!(decoded.get_pixel(0, 0).0, [rgb[0], rgb[1], rgb[2], 255]);
assert!(Arc::ptr_eq(&before, &s.doc));
+52 -38
View File
@@ -1,3 +1,7 @@
use crate::schema::render_jobs::{
ExportFormat, ImageCopied, ImageExported, Job, JobProgressReply, JobState, PreparationCancelled, QueueReply, RenderCancelled, RenderProgress,
ResultPinned,
};
use crate::{Result, Session, params::bad, render_jobs::job_error};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -868,10 +872,19 @@ struct Regrade {
presentation: solidcraft_render::job::presentation::Presentation,
}
pub(super) fn regrade(s: &mut Session, params: &Value) -> Result<Value> {
let input: Regrade = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.regrade", e.to_string()))?;
let cmd = "render.result.regrade";
let input: Regrade = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
input.presentation.validate().map_err(job_error)?;
let job = result_source(s, input.job, "render.result.regrade")?;
Ok(json!(s.render_jobs.regrade(job, input.presentation).map_err(job_error)?))
let job = result_source(s, input.job, cmd)?;
let status = s.render_jobs.regrade(job, input.presentation).map_err(job_error)?;
super::typed_reply(cmd, retyped::<JobState>(cmd, &status)?)
}
/// `value`, a render crate type without a schema, as the reply type `T` that describes the same
/// JSON (`schema::render_jobs`). The JSON doesn't change; a value `T` can't read back is refused
/// rather than sent out unlike its schema.
fn retyped<T: serde::de::DeserializeOwned>(cmd: &str, value: &impl serde::Serialize) -> Result<T> {
serde_json::to_value(value).and_then(serde_json::from_value).map_err(|e| bad(cmd, e.to_string()))
}
fn result_source(s: &mut Session, job: Option<JobId>, command: &str) -> Result<JobId> {
@@ -880,16 +893,15 @@ fn result_source(s: &mut Session, job: Option<JobId>, command: &str) -> Result<J
}
pub(super) fn status(s: &mut Session, params: &Value) -> Result<Value> {
let input: ResultJob = serde_json::from_value(params.clone()).map_err(|e| bad("render.status", e.to_string()))?;
let cmd = "render.status";
let input: ResultJob = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
s.advance_render_batch()?;
let Some(job) = input.job else {
return Ok(json!({"queue":s.render_queue_state()}));
return super::typed_reply(cmd, RenderProgress::Queue(QueueReply { queue: s.render_queue_state(), param_warnings: Vec::new() }));
};
let mut reply = json!(s.render_jobs.status(job).map_err(job_error)?);
if let Some(object) = reply.as_object_mut() {
object.insert("queue".into(), json!(s.render_queue_state()));
}
Ok(reply)
// The job's status first: reading it collects finished jobs, which the queue then counts.
let state = retyped(cmd, &s.render_jobs.status(job).map_err(job_error)?)?;
super::typed_reply(cmd, RenderProgress::Job(Box::new(JobProgressReply { state, queue: s.render_queue_state() })))
}
#[derive(Deserialize)]
@@ -898,9 +910,11 @@ struct Pin {
job: Option<JobId>,
}
pub(super) fn pin(s: &mut Session, params: &Value) -> Result<Value> {
let input: Pin = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.pin", e.to_string()))?;
let job = if params.get("job").is_some_and(Value::is_null) { None } else { Some(result_source(s, input.job, "render.result.pin")?) };
Ok(json!({"frame":s.render_jobs.pin(job).map_err(job_error)?}))
let cmd = "render.result.pin";
let input: Pin = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
let job = if params.get("job").is_some_and(Value::is_null) { None } else { Some(result_source(s, input.job, cmd)?) };
let frame = s.render_jobs.pin(job).map_err(job_error)?.map(|info| retyped(cmd, &info)).transpose()?;
super::typed_reply(cmd, ResultPinned { frame, param_warnings: Vec::new() })
}
#[derive(Deserialize)]
@@ -923,7 +937,7 @@ pub(super) fn copy(s: &mut Session, params: &Value) -> Result<Value> {
if solidcraft_render::RenderLimits::default().fit(size.0, size.1, None) != Some(size) {
return Err(bad("render.result.copy", "clipboard image exceeds 4096 per side or 4194304 pixels; export the PNG instead"));
}
Ok(json!({"frame":frame.info}))
super::typed_reply("render.result.copy", ImageCopied { frame: retyped("render.result.copy", &frame.info)?, param_warnings: Vec::new() })
}
pub(crate) fn cancel_params(params: &Value) -> Result<Option<JobId>> {
@@ -933,34 +947,19 @@ pub(crate) fn cancel_params(params: &Value) -> Result<Option<JobId>> {
Ok(input.job)
}
pub(super) fn cancel(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.cancel";
let requested = cancel_params(params)?;
if requested.is_none() && s.cancel_native_preparation() {
return Ok(json!({"preparing":true,"cancel_requested":true,"queue":s.render_queue_state()}));
let stopped = PreparationCancelled { preparing: true, cancel_requested: true, queue: s.render_queue_state(), param_warnings: Vec::new() };
return super::typed_reply(cmd, RenderCancelled::Preparation(stopped));
}
let job = requested.or(s.render_queue_state().job).ok_or_else(|| bad("render.cancel", "job is required without an active shot queue"))?;
let job = requested.or(s.render_queue_state().job).ok_or_else(|| bad(cmd, "job is required without an active shot queue"))?;
let status = s.render_jobs.status(job).map_err(job_error)?;
s.cancel_render_batch(job);
if status.progress.phase.terminal() {
return Ok(json!(status));
}
Ok(json!(s.render_jobs.handle(job).map_err(job_error)?.cancel().map_err(job_error)?))
let status = if status.progress.phase.terminal() { status } else { s.render_jobs.handle(job).map_err(job_error)?.cancel().map_err(job_error)? };
super::typed_reply(cmd, RenderCancelled::Job(Box::new(retyped(cmd, &status)?)))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ExportFormat {
#[default]
Png,
Exr,
}
impl ExportFormat {
fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Exr => "exr",
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Export {
@@ -992,14 +991,29 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
.linear_exr()
.ok_or_else(|| bad("render.export", "this completed job has no raw linear master; enable retention before rendering"))?,
};
// Typed before the write: a conversion that fails then leaves no file behind.
let frame_info = retyped("render.export", &frame.info)?;
let linear_master = frame.linear_master_info().map(|info| retyped("render.export", info)).transpose()?;
let presentation = frame.presentation().map(|info| retyped("render.export", info)).transpose()?;
// Replace atomically without reading an arbitrarily large previous target into memory.
// Design saves retain their independent backup policy.
let result =
if input.overwrite { solidcraft_io::write_atomic(path, bytes, false) } else { solidcraft_io::safe_write::create_atomic(path, bytes) };
result.map_err(|e| bad("render.export", format!("could not export completed {}: {e}", input.format.extension())))?;
Ok(
json!({"job":job,"path":input.path,"bytes":bytes.len(),"format":input.format.extension(),"encoding":if input.format==ExportFormat::Exr {"scene_linear_srgb_d65"} else {"srgb"},"alpha":if input.format==ExportFormat::Exr {"coverage_premultiplied"} else {"straight"},"frame":frame.info,"linear_master":frame.linear_master_info(),"presentation":frame.presentation()}),
)
let exr = input.format == ExportFormat::Exr;
let reply = ImageExported {
job: Job(job),
bytes: bytes.len(),
path: input.path,
format: input.format,
encoding: if exr { "scene_linear_srgb_d65" } else { "srgb" },
alpha: if exr { "coverage_premultiplied" } else { "straight" },
frame: frame_info,
linear_master,
presentation,
param_warnings: Vec::new(),
};
super::typed_reply("render.export", reply)
}
fn validate_studio_weights(weights: Option<[f64; 3]>, command: &str) -> Result<Option<[f64; 3]>> {
+1 -1
View File
@@ -401,7 +401,7 @@ pub(super) fn apply_appearance(s: &mut Session, params: &Value) -> Result<Value>
let value = item("surfaces", &input.id, command)?;
let lo = triple(&value["base_linear_low"], command)?;
let hi = triple(&value["base_linear_high"], command)?;
let color = std::array::from_fn::<_, 3, _>(|i| crate::geom::srgb::encode8((lo[i] + hi[i]) * 0.5));
let color = std::array::from_fn::<_, 3, _>(|i| solidcraft_color::srgb::encode8((lo[i] + hi[i]) * 0.5));
(
value,
LibraryLook {
+76 -25
View File
@@ -14,6 +14,7 @@ use crate::schema::sketch::{
EditResult, EqualMade, EqualParams, EqualResult, ExitParams, FinishResult, FixOutcome, FixParams, FixResult, HorizontalVerticalParams,
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
};
use crate::schema::sketch_dimension::{DimensionResult, SketchDimensionParams};
use crate::schema::sketch_session::{
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
@@ -22,7 +23,8 @@ use crate::schema::sketch_session::{
use crate::schema::sketch_shapes::{
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, Created, DrawResult, LineParams,
PointParams, PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectCorners,
RectResult, RectRoundedParams, RectSides, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
RectResult, RectRoundedParams, RectSides, RectThreePointParams, RectTwoPointParams, RoundedRectResult, SlotCenterToCenterParams,
SlotOverallParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
@@ -76,10 +78,13 @@ const COLLINEAR_CONTRACT: TypedContract<CollinearParams> = TypedContract::new();
const SYMMETRY_CONTRACT: TypedContract<SymmetryParams> = TypedContract::new();
const LINE_CONTRACT: TypedContract<LineParams> = TypedContract::new();
const POINT_CONTRACT: TypedContract<PointParams> = TypedContract::new();
const DIMENSION_CONTRACT: TypedContract<SketchDimensionParams> = TypedContract::new();
const CIRCLE_CENTER_CONTRACT: TypedContract<CircleCenterParams> = TypedContract::new();
const CIRCLE_TWO_POINT_CONTRACT: TypedContract<CircleTwoPointParams> = TypedContract::new();
const CIRCLE_THREE_POINT_CONTRACT: TypedContract<CircleThreePointParams> = TypedContract::new();
const ARC_THREE_POINT_CONTRACT: TypedContract<ArcThreePointParams> = TypedContract::new();
const SLOT_CENTER_TO_CENTER_CONTRACT: TypedContract<SlotCenterToCenterParams> = TypedContract::new();
const SLOT_OVERALL_CONTRACT: TypedContract<SlotOverallParams> = TypedContract::new();
const ARC_CENTER_POINT_CONTRACT: TypedContract<ArcCenterPointParams> = TypedContract::new();
const RECT_TWO_POINT_CONTRACT: TypedContract<RectTwoPointParams> = TypedContract::new();
const RECT_THREE_POINT_CONTRACT: TypedContract<RectThreePointParams> = TypedContract::new();
@@ -205,18 +210,29 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&POLYGON_EDGE_CONTRACT)
.output(output_schema_for::<PolygonResult>)
.params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", crate::checked!("sketch.slot.center_to_center", slot_c2c))
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.contract(&SLOT_CENTER_TO_CENTER_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
CommandSpec::new("sketch.slot.overall", "Overall Slot", crate::checked!("sketch.slot.overall", slot_overall))
.at("SKETCH", "CREATE")
.icon("slot_overall")
.enabled(in_sketch)
.contract(&SLOT_OVERALL_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", crate::checked!("sketch.point", draw_point)).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).contract(&POINT_CONTRACT).output(output_schema_for::<PointResult>).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
CommandSpec::new("sketch.dimension", "Sketch Dimension", crate::checked!("sketch.dimension", dimension))
.at("SKETCH", "CREATE")
.icon("dimension")
.key("D")
.enabled(in_sketch)
.contract(&DIMENSION_CONTRACT)
.output(output_schema_for::<DimensionResult>)
.params(
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|major|minor|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them (between the perpendiculars through them, or a tenth of their distance past) is the distance between them; pulled further past an end, up or down from it horizontal, sideways vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically, anywhere else (inside it or past its corners) the distance between them. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An ellipse: a half-axis, the distance from the centre to an axis end (major or minor; with auto, text_at pulled across the major axis gives the major half-axis, pulled out past an end of it the minor one, the minor without text_at; previous_axis?: major|minor, the half-axis an earlier placement chose, kept while text_at is near the boundary between them; the reply's axis names the one chosen; with auto, when a driving dimension already holds one half-axis (or its whole axis), the other one, wherever text_at is; without its major axis line the major half-axis goes to the ellipse's own major end, without its minor axis line the minor half-axis is the minor radius); any other type, radius among them, the minor radius; an ellipse's axis line with auto: its length, the whole axis, wherever text_at is; half_axes?: bool (default true; false: with auto, the minor radius wherever text_at is, as before half-axes, which the Dimension tool sends with Draw ellipses with their axes off). An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends the Sketch preference, on by default): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", crate::checked!("sketch.constraint.horizontal_vertical", c_horizontal_vertical))
@@ -2053,7 +2069,7 @@ fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result<Value> {
let (out, info) = slot_parts(s, p, cmd, c0, c1, w)?;
Ok(result(out, info))
draw_reply(out, info)
}
/// What [`slot`] adds: the curve and constraint ids, and the sketch's solve.
@@ -2797,8 +2813,11 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let id = p.get(key).and_then(Value::as_str).unwrap_or_default();
bad(cmd, format!("`{id}` is not the end of one curve: pick a curve, or the end point of a line or an arc"))
};
let mut before = None;
let mut residual = None;
let (out, info) = edit(s, p, cmd, true, |sk, _| {
let rep = solve(&mut sk.clone());
before = Some(rep.dof);
let (pa, pb) = (tangent_end(sk, p, "a"), tangent_end(sk, p, "b"));
let ca = if pa.is_none() { Some(line_or_axis_ref(sk, p.get("a"), cmd, "a")?) } else { None };
let cb = if pb.is_none() { Some(line_or_axis_ref(sk, p.get("b"), cmd, "b")?) } else { None };
@@ -2827,6 +2846,12 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
let tangent = add_c(sk, ConstraintKind::Tangent { a, b })?;
ids.push(tangent.clone());
// As in `constrain`: what already holds needs nothing moved, and is then redundant when it
// removes no degree of freedom (see `redundancy`).
residual = ids.iter().try_fold(0.0_f64, |worst, id| residual_of(sk, id).map(|r| worst.max(r)));
if residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT) {
return Ok((tangent, ids));
}
// A line's end and a curve, in either order: only that end moves, so the line turns about
// its other end and gets longer or shorter to meet the curve (#12092). At a joint that end
// can't leave the other curve: the curve it ends stays, and the other curve moves. Otherwise,
@@ -2848,7 +2873,12 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
Ok((tangent, ids))
})?;
let (constraint, constraints) = out;
reply(cmd, TangentResult { made: TangentMade::End { constraint, constraints, sketch: info }, param_warnings: Vec::new() })
let by = redundancy(s, p, cmd, before, &info, residual, &constraint)?;
if let Some(by) = &by {
keep_redundant(p, cmd, by)?;
}
let made = TangentMade::End { constraint, constraints, sketch: info, redundant: by.as_ref().map(|_| true), determined_by: by };
reply(cmd, TangentResult { made, param_warnings: Vec::new() })
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("curves").is_some() {
@@ -3229,6 +3259,11 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
}
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
reply("sketch.dimension", dimension_result(s, p)?)
}
/// [`dimension`]'s reply, as the typed value the output schema describes.
fn dimension_result(s: &mut Session, p: &Value) -> Result<DimensionResult> {
let cmd = "sketch.dimension";
// A dimension may measure model geometry (#7912): an `{edge: [x,y,z], body?}` or a
// `{vertex: [x,y,z], body?}` among its entities is projected into the sketch first, as
@@ -3578,11 +3613,22 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
// Report what the dimension measures once the sketch is solved, not the geometry before.
let current = solved_measure(s, p, cmd).unwrap_or(current);
if driven {
let mut out = json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info});
if let Some(axis) = axis {
out["axis"] = json!(axis);
}
return Ok(out);
return Ok(DimensionResult {
param: None,
driven: Some(true),
kind: kind_name.to_string(),
expression: None,
measured: current.0,
unit: current.1.to_string(),
sketch: info,
scaled: None,
axis: axis.map(str::to_string),
defined: None,
redundant: None,
determined_by: None,
created: None,
param_warnings: Vec::new(),
});
}
// A dimension the sketch already determines cannot drive it: it is refused, or with
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
@@ -3597,9 +3643,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
o.remove("if_redundant");
o.remove("value");
}
let mut out = dimension(s, &q)?;
out["redundant"] = json!(true);
out["determined_by"] = json!(by);
let mut out = dimension_result(s, &q)?;
out.redundant = Some(true);
out.determined_by = Some(by);
return Ok(out);
}
return Err(bad(
@@ -3608,17 +3654,22 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
let mut out = json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info});
if let Some(factor) = scaled {
out["scaled"] = json!(round6(factor));
}
if let Some(axis) = axis {
out["axis"] = json!(axis);
}
if let Some((name, _)) = defines {
out["defined"] = json!(name);
}
Ok(out)
Ok(DimensionResult {
param: Some(param),
driven: None,
kind: kind_name.to_string(),
expression: Some(v),
measured: current.0,
unit: current.1.to_string(),
sketch: info,
scaled: scaled.map(round6),
axis: axis.map(str::to_string),
defined: defines.map(|(name, _)| name),
redundant: None,
determined_by: None,
created: None,
param_warnings: Vec::new(),
})
}
/// A dimension's measured value as reported, in the unit its expression is written in.
+5 -3
View File
@@ -428,8 +428,10 @@ fn add_link_unless_present(
if skip_present && r.wires.is_empty() && sk.link_geometry_present(&r.geom) {
return Ok(None);
}
// A mesh body is one face whose name says nothing of the flat patch picked: the link keeps
// the point instead (#13464), as an edge of a mesh body keeps no name.
let name = if let LinkSource::Face { body, at } = &r.source {
st.body(body).and_then(|body| {
st.body(body).filter(|body| !body.body.is_mesh()).and_then(|body| {
if let Some(index) = r.face_index {
solidcraft_doc::naming::face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
} else {
@@ -582,7 +584,7 @@ pub(super) fn project_operands(s: &mut Session, p: &Value, cmd: &str, keys: &[&s
/// A model's reference among a command's operands (#6348): an `{edge: [x,y,z], body?}` or a
/// `{vertex: [x,y,z], body?}`.
fn model_ref(v: &Value) -> bool {
pub(crate) fn model_ref(v: &Value) -> bool {
v.get("edge").is_some() || v.get("vertex").is_some()
}
@@ -601,7 +603,7 @@ fn project_ref(s: &mut Session, doc: &Document, id: u64, sk: &mut Sketch, v: &Va
/// Most model references one call may project into a sketch: each one is a link and a resolve,
/// so the count is capped before any of them is projected.
const MAX_PROJECTED_ENTITIES: usize = 1_000;
pub(crate) const MAX_PROJECTED_ENTITIES: usize = 1_000;
/// A dimension's entities may be model geometry (#7912): an `{edge: [x,y,z], body?}` or
/// `{vertex: [x,y,z], body?}` among them is projected into the sketch first, as Project does,
@@ -1803,3 +1803,87 @@ fn projected_references_can_start_as_construction() {
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}], "construction": false}));
assert_eq!(flags(&mut s), vec![true, false]);
}
/// #13464: an imported mesh is one face with no edges, so Project on one of its faces projected
/// nothing. The pick now means the flat patch of triangles there: a box's top projects as its
/// rectangle, its side seen edge-on as a line, and the link keeps the pick's point (a mesh's face
/// name says nothing of the patch), so the sketch still builds once finished.
#[test]
fn a_face_of_an_imported_mesh_projects_its_flat_patch() {
let dir = std::env::temp_dir().join(format!("solidcraft-project-mesh-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let stl = dir.join("box.stl");
let mut src = Session::default();
run(&mut src, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
run(&mut src, "file.export_stl", json!({"path": stl.to_string_lossy()}));
let mut s = Session::default();
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
let body = s.model.state().bodies[0].name.clone();
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 20}));
run(&mut s, "sketch.project", json!({"refs": [{"body": body, "face": [10, 5, 5]}]}));
let si = inspect(&mut s);
let lines = linked_curves(&si);
assert_eq!(lines.len(), 4, "{si}");
let mut corners: Vec<(i64, i64)> = lines
.iter()
.flat_map(|c| [&c["start_at"], &c["end_at"]])
.map(|p| ((p[0].as_f64().unwrap() * 1e6).round() as i64, (p[1].as_f64().unwrap() * 1e6).round() as i64))
.collect();
corners.sort_unstable();
corners.dedup();
assert_eq!(corners, [(0, 0), (0, 10_000_000), (20_000_000, 0), (20_000_000, 10_000_000)], "{si}");
assert!(s.active_sketch.is_some());
run(&mut s, "sketch.finish", json!({}));
let failing: Vec<_> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
assert!(failing.is_empty(), "{failing:?}");
// A side seen edge-on: the line it makes.
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 20}));
run(&mut s, "sketch.project", json!({"refs": [{"body": body, "face": [10, 0, 2.5]}]}));
assert_eq!(linked_curves(&inspect(&mut s)).len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// #13464: on a sketch plane at a slant to the face, a flat patch's polygon keeps its straight
/// sides. A rectangle's four corners and a triangle's three always lie on one circle, so they must
/// not be taken for a circle seen at a slant (an ellipse); a tessellated circle still is one.
#[test]
fn a_mesh_face_keeps_its_straight_sides_on_a_slanted_plane() {
let dir = std::env::temp_dir().join(format!("solidcraft-project-mesh-slant-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut src = Session::default();
run(&mut src, "solid.box", json!({"length": 20, "width": 10, "height": 5, "body_name": "box"}));
run(&mut src, "sketch.create", json!({"plane": "XY"}));
run(&mut src, "sketch.line", json!({"points": [[30, 0], [42, 0], [34, 9], [30, 0]]}));
run(&mut src, "sketch.finish", json!({}));
let sketch = src.doc.features.last().unwrap().id;
run(&mut src, "solid.extrude", json!({"sketch": sketch, "distance": 5, "body_name": "tri"}));
run(&mut src, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 7, "height": 5}));
let mut stls = Vec::new();
for body in src.model.state().bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>() {
let path = dir.join(format!("{body}.stl"));
run(&mut src, "file.export_stl", json!({"path": path.to_string_lossy(), "bodies": [body]}));
stls.push(path);
}
let mut s = Session::default();
for stl in &stls {
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
}
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
run(&mut s, "construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": "30 deg", "name": "Tilt"}));
let kinds = |s: &mut Session, body: &str, at: [f64; 3]| -> Vec<String> {
run(s, "sketch.create", json!({"plane": "Tilt"}));
run(s, "sketch.project", json!({"refs": [{"body": body, "face": at}]}));
let kinds = linked_curves(&inspect(s)).iter().map(|c| c["type"].as_str().unwrap_or("").to_string()).collect();
run(s, "sketch.finish", json!({}));
kinds
};
assert_eq!(kinds(&mut s, &names[0], [10., 5., 5.]), ["line"; 4], "the box's top is a rectangle");
assert_eq!(kinds(&mut s, &names[1], [35., 3., 5.]), ["line"; 3], "the prism's top is a triangle");
let round = kinds(&mut s, &names[2], [60., 0., 5.]);
assert_eq!(round.len(), 1, "the cylinder's top is one curve: {round:?}");
assert_eq!(round[0], "ellipse", "the cylinder's round top, seen at a slant: {round:?}");
std::fs::remove_dir_all(&dir).ok();
}
+62
View File
@@ -397,6 +397,68 @@ fn tangent_at_a_joint_adds_only_the_tangent() {
}
}
/// Tangent on a line's end and a circle the end already touches tangentially, as for two curves
/// (`constrain`): what already holds moves nothing. With both fixed the tangent removes no degree
/// of freedom, so it is kept and reported as redundant, or refused with `if_redundant: "refuse"`,
/// leaving the sketch as it was.
#[test]
fn tangent_at_an_end_that_already_holds_is_redundant() {
let touching = |fixed: bool| {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[5, 3], [20, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 8], "radius": 5}));
if fixed {
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "c1"], "fixed": true}));
}
s
};
for (a, b) in [("l1.end", "c1"), ("c1", "l1.end")] {
let mut s = touching(false);
let (line, circle) = (snap(&s, "l1"), snap(&s, "c1"));
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert!(same(&snap(&s, "l1"), &line) && same(&snap(&s, "c1"), &circle), "{a}, {b}: something moved");
assert!(r.get("redundant").is_none(), "{a}, {b}: free, so not redundant: {r}");
let mut s = touching(true);
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert_eq!(r["redundant"], true, "{a}, {b}: {r}");
// Fixed entities are held, not constrained, so no constraint is listed.
assert!(r["determined_by"].is_array(), "{a}, {b}: {r}");
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
let mut s = touching(true);
let count = sketch(&s).constraints.len();
let e = s.execute("sketch.constraint.tangent", &json!({"a": a, "b": b, "if_redundant": "refuse"})).unwrap_err().to_string();
assert!(e.contains("already determined by"), "{a}, {b}: {e}");
assert_eq!(sketch(&s).constraints.len(), count, "{a}, {b}: nothing added");
}
}
/// The same with free curves whose own constraints already decide it: circle c2 is concentric
/// with c1 and equal to it, the line is tangent to c2 and its end lies on c2. Tangent on that end
/// and c1 adds the end on c1 and the tangent, and names what determines the tangent: at the end
/// it says the line is square to the radius there, so the shared centre and the tangent on c2
/// decide it, and the equal radius does not.
#[test]
fn tangent_at_an_end_names_the_constraints_that_determine_it() {
for (a, b) in [("l1.end", "c1"), ("c1", "l1.end")] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[5, 3], [20, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 8], "radius": 5}));
run(&mut s, "sketch.circle.center", json!({"center": [40, 30], "radius": 2}));
let mut id = |cmd: &str, p: Value| run(&mut s, cmd, p)["constraint"].as_str().unwrap_or_default().to_string();
let concentric = id("sketch.constraint.concentric", json!({"a": "c1", "b": "c2"}));
id("sketch.constraint.equal", json!({"a": "c1", "b": "c2"}));
let tangent = id("sketch.constraint.tangent", json!({"a": "l1", "b": "c2"}));
id("sketch.constraint.coincident", json!({"a": "l1.end", "b": "c2"}));
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert_eq!(r["redundant"], true, "{a}, {b}: {r}");
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
let by = json!([format!("{concentric} (Concentric)"), format!("{tangent} (Tangent)")]);
assert_eq!(r["determined_by"], by, "{a}, {b}: {r}");
}
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
+36 -8
View File
@@ -14,20 +14,36 @@ use solidcraft_kernel::{Construction, Continuity};
use super::CommandSpec;
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
use crate::params::{bad, bool_, expr, num, str_, string_list, vec3};
use crate::schema::features::{FeatureResult, SurfaceSweepParams};
use crate::schema::surface::{FairParams, OffsetParams, ReverseNormalParams, StitchParams, ThickenParams, UnstitchParams, UntrimParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const STITCH_CONTRACT: TypedContract<StitchParams> = TypedContract::new();
const UNSTITCH_CONTRACT: TypedContract<UnstitchParams> = TypedContract::new();
const REVERSE_NORMAL_CONTRACT: TypedContract<ReverseNormalParams> = TypedContract::new();
const THICKEN_CONTRACT: TypedContract<ThickenParams> = TypedContract::new();
const OFFSET_CONTRACT: TypedContract<OffsetParams> = TypedContract::new();
const UNTRIM_CONTRACT: TypedContract<UntrimParams> = TypedContract::new();
const FAIR_CONTRACT: TypedContract<FairParams> = TypedContract::new();
const SURFACE_SWEEP_CONTRACT: TypedContract<SurfaceSweepParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("surface_patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2|g3|g4 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges, g3 also its rate of change, g4 the change of that rate) or [{edge: [x,y,z], g: g0|g1|g2|g3|g4}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or higher edge); name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
CommandSpec::new("surface.stitch", "Stitch", crate::checked!("surface.stitch", stitch))
.at("SURFACE", "MODIFY")
.icon("surface_stitch")
.contract(&STITCH_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"),
CommandSpec::new("surface.thicken", "Thicken", thicken)
CommandSpec::new("surface.thicken", "Thicken", crate::checked!("surface.thicken", thicken))
.at("SURFACE", "CREATE")
.icon("surface_thicken")
.contract(&THICKEN_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"),
CommandSpec::new("surface.extend", "Extend", extend)
.at("SURFACE", "MODIFY")
@@ -45,17 +61,21 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "CREATE")
.icon("surface_revolve")
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{type: world, axis: X|Y|Z}|construction axis name|{origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
CommandSpec::new("surface.sweep", "Surface Sweep", crate::checked!("surface.sweep", surface_sweep))
.at("SURFACE", "CREATE")
.icon("surface_sweep")
.contract(&SURFACE_SWEEP_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
.at("SURFACE", "CREATE")
.icon("surface_loft")
.params("sections: [{sketch, curves?: [curve ids], profiles?} | {body, edges: [[x,y,z]]}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed; a body edge chain takes the face the chain bounds as the surface the end repeats); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g0: how much of the surface at the first and last section the loft repeats — the tangent plane, the curvature, or how that curvature changes); crown?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); rails?, rail_fit? (as solid.loft; sketch sections only, both ends g0); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"),
CommandSpec::new("surface.offset", "Offset Surface", offset)
CommandSpec::new("surface.offset", "Offset Surface", crate::checked!("surface.offset", offset))
.at("SURFACE", "CREATE")
.icon("surface_offset")
.contract(&OFFSET_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"),
CommandSpec::new("surface.ruled", "Ruled Surface", ruled)
.at("SURFACE", "CREATE")
@@ -65,21 +85,29 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "CREATE")
.icon("surface_blend")
.params("from, to: {body, edges: [[x,y,z]]} (points on the edges of one chain of each body: two picks on the same edge is that one edge, more chain up); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g1: how much of the surface at the picked edges the blend repeats — the tangent plane, the curvature, or how that curvature changes); bulge?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); align?: [[x,y,z], [x,y,z]] (a point each end leaves towards, when the faces at the picks do not say); seam?: [[x,y,z], [x,y,z]] (a point on each chain its stations start from: two closed chains otherwise pair the way that keeps the blend from twisting; an open chain starts at the end nearer its point; a closed chain blends only to a closed chain); stitch?: bool (sew the blend onto its two bodies where the boundaries meet, replacing them); name?, body_name?"),
CommandSpec::new("surface.untrim", "Untrim", untrim)
CommandSpec::new("surface.untrim", "Untrim", crate::checked!("surface.untrim", untrim))
.at("SURFACE", "MODIFY")
.icon("surface_untrim")
.contract(&UNTRIM_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove; default: all); outer?: bool (restore the outer boundary to the surface's natural bounds: a free-form surface's own, an analytic surface's span of the face); in_place?: bool (untrim a surface body's face where it is; a solid's face is always copied out). A surface body; the face keeps its name"),
CommandSpec::new("surface.unstitch", "Unstitch", unstitch)
CommandSpec::new("surface.unstitch", "Unstitch", crate::checked!("surface.unstitch", unstitch))
.at("SURFACE", "MODIFY")
.icon("surface_unstitch")
.contract(&UNSTITCH_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body; faces?: [[x,y,z]] (points on the faces to take out, each its own surface body; the rest stay together under the body's name; default: every face its own surface body). A solid comes apart into surfaces"),
CommandSpec::new("surface.reverse_normal", "Reverse Normal", reverse_normal)
CommandSpec::new("surface.reverse_normal", "Reverse Normal", crate::checked!("surface.reverse_normal", reverse_normal))
.at("SURFACE", "MODIFY")
.icon("surface_reverse_normal")
.contract(&REVERSE_NORMAL_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body (a surface); faces?: [[x,y,z]] (points on the faces to turn over; default: all of them)"),
CommandSpec::new("surface.fair", "Fair Surface", fair)
CommandSpec::new("surface.fair", "Fair Surface", crate::checked!("surface.fair", fair))
.at("SURFACE", "MODIFY")
.icon("surface_fair")
.contract(&FAIR_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body: surface name; faces?: [[x,y,z]] points on the faces to fair (default: every face); strength?: number (0..1 of the full fairing step, default 1); iterations?: number (1..1000 steps, default 1); keep_edges?: bool (default true: hold the boundary row and the row next to it, so a face keeps meeting its neighbours in position and in tangent); tolerance?: expr mm (how far a control vertex may move, default 0.001 of the body's size). Moves the interior control vertices of each B-spline face to flatten its curvature variation while the boundary stays put: the face keeps its edges and the body its shape within the tolerance, and the warning in the reply carries the energy before and after; name?"),
planned!("surface.fillet", "Surface Fillet", "769")
.at("SURFACE", "MODIFY")

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