Compare commits

...

13 Commits

Author SHA1 Message Date
Brennen Herbruck 5dde4647b1 PR checks: accept a kernel result change under solidcore/ without a preference
solidcore's rule (solidcore/AGENTS.md §4b) makes a kernel fix the new default with
no flag, so 'Changes a default: yes — <what>; kernel result change, no preference'
now passes for PRs that only change solidcore/. App changes still keep their old
behaviour behind a preference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:20:09 -05:00
PeppaWutz 53ce0e47b1 Agents and scripts: Draw Dimensioned Sketch Geometry describes its exact parameters and reply (#13313) (#13354)
* Agents and scripts: Draw Dimensioned Sketch Geometry describes its exact parameters and reply (#13313)

sketch.draw.dimensioned now publishes typed input and output schemas. Each
parameter is read as the handler reads it today, with the same error
messages, and the reply is built from the typed value and serializes to the
same JSON as before. Its line leaves unconverted.txt.

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

* Keep a nested command's parameter warnings in warn mode

A checked command that runs another checked command, as Draw Dimensioned
runs its creator, now adds its own warnings to the inner ones instead of
replacing them.

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

* Fix unconverted.txt after merge

* Format the imports after merging main

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 23:38:29 +00:00
SS-EN f46eae28c7 Joint drive: only features that read a pose-dependent mirror's copies recompute (#12139) (#12177)
* Joint drive: only features that read a pose-dependent mirror's copies recompute (#12139)

A mirror of placed bodies hashed its placement into the fingerprint chain, so a
joint drive changed the fingerprint of every later feature and they all
recomputed. Its placement is now checked on its own (Model::poses); when only
its pose changed, the features after it that read none of its copies keep their
results with its new copies in place. A feature that reads them (or might:
picked faces, projections, To Object, ...) recomputes, and everything after it.

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

* Adopted model: don't seed pose-only mirror results into the cache (#12139)

A pose-only mirror's fingerprint leaves its pose out, so a saved result can be
from another pose than the design's. Model::adopt put it in the cache under the
design's placement key, and the next evaluation took it instead of mirroring
again. Those results are no longer seeded.

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

* Adopted model: evaluate a pose-only mirror's saved result again (#12139)

When the saved file kept every state, Model::adopt returned without evaluating,
so a pose-only mirror saved at another pose stayed in the results the design
opens with. A design with one is now evaluated on adoption: the mirror again,
the features that read none of its copies kept.

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

* Recompute a revolve about a model axis after a pose-only mirror (#12139)

A revolve's model axis is a pick of the geometry, not a body by name, so
the read set of its drawn targets misses a mirrored copy it turns about.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:21:21 +00:00
austin62jake ea4164dabb Agents and scripts: Split Body and Split Face describe their exact parameters and reply (#13127) (#13128)
* Claim #13127: Split Body and Split Face schemas

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

* Split Body and Split Face: input and output schemas (#13127)

solid.split_body gets SplitBodyParams and solid.split_face SplitFaceParams in a new
schema/split.rs; both reply with FeatureResult. The cutting tool keys (tool, sketch,
line, plane) are checked for their form whenever given; which one the handler uses,
and a call with none, stay the handler's.

The tool face gets ToolFace in schema/types.rs, with the handlers' messages for
tool.body and tool.point; surface.trim reads tool the same way and can share it.
Split Body's sketch is a feature id only (SketchFeatureId), as its handler reads it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-08 23:05:26 +00:00
0rdnungsamt af16e55217 Mirror: mirroring a hole with its Thread feature builds the thread on the mirrored hole (#13278) (#13381)
* claim: #13278

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

* Mirror: a Thread feature is cut again on the mirrored hole (#13278)

Mirror and Pattern refused a Thread feature, so a hole with its thread
could not be mirrored. Replay now moves the thread's face pick by each
copy's transform and evaluates the thread there. A copy keeps the
thread's hand. A thread that ends at a plane is still refused.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:54:06 +00:00
1993eg1 c5fd9bc518 Add Chamfer command guide (machinist perspective) (#13521)
* Add Chamfer command guide (machinist perspective)

User guide for the Chamfer command in v0.30.1: Equal Distance, Two Distances, and Distance and Angle, picking edges vs a whole face, Flip, Tangent chain, and why a chamfer next to an existing small chamfer can fail.

* Chamfer guide: Equal Distance is 45° only on a square corner

Per review: Equal Distance gives a 45° chamfer only where the faces meet at 90°. Recommends Distance and Angle whenever the print gives an angle and for chamfers on sloped faces.
2026-11-08 22:49:35 +00:00
1993eg1 7cd4bc3b2c Add Thread command guide (machinist perspective) (#13507)
* Add Thread command guide (machinist perspective)

User guide for the Thread command in v0.30.1: Automatic size, coarse vs fine pitch, modeled vs callout threads, and thread length.

* Update Thread guide for the v0.40 dialog

Rewrites the guide for the new Thread dialog per review: Extent (Full / Length / Up to) replaces Full Length, Automatic size is on by default, the Form list adds Trapezoidal, Acme, Buttress and pipe threads, Ends (Start chamfer, End square or fade), and a tip to click the face near the end to thread or pick Start end. Notes that UNC/UNF are only on Hole tapped holes for now. Tested on v0.40.0-rc.3.
2026-11-08 22:48:21 +00:00
1993eg1 0d0a7327d9 Update Hole guide for the v0.40 dialog (#13547)
* Update Hole guide for the v0.40 dialog

Hand replaces Thread direction; adds the new Modeled checkbox and End (Square / Fade); Tap drill diameter can now be typed, with the accepted range (minor diameter to 5% over the tap maker's drill). Presets carry over to every design. Tested on v0.40.0-rc.3.

* Hole guide: clarify tap drill range and custom metric default

Per review: the lowest accepted drill is the smaller of the minor diameter and the tap maker's drill (pipe taps such as Rp 1/4 run under the minor diameter). For custom metric sizes not in the table, the default drill is major diameter minus pitch, so the guide now says to check the tap maker's chart.

* Hole guide: standard tap drill wording and tap drill table fallback

Says "standard tap drill" instead of "tap maker's drill" (noting the app's messages use the latter), and per review clarifies that the major-minus-pitch default only applies to custom metric sizes with no entry in the app's tap drill table.
2026-11-08 22:47:26 +00:00
Nathan Dane ff6ca84915 Assistant: adapt shared panels to wide layouts (#11311) (#13514)
* claim: #11311 Assistant responsive panel completion

* Assistant: reuse content regions in wide shared panels (#11311)

* Update the retained Assistant composer test for explicit flow

* Panels: retain editor focus through floating sizing passes

* Panels: preserve eligible editor focus across metadata refreshes
2026-11-08 22:44:56 +00:00
bherbruck eabbbc4c3d Retire the kernel pause: kernel work goes in solidcore/ (#13824)
* kernel-paused: don't pause solidcore/

Kernel work now happens in solidcore/ (vendored in #13173), behind its code
owners' review; the pause only covers SolidCraft's own crates/kernel and
crates/geom.

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

* Retire the kernel pause: kernel work goes in solidcore/

Delete the kernel-paused bot and its CI step, replace the CONTRIBUTING
section, and update the skills and docs that said kernel work is paused.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:38:05 +00:00
bherbruck 0917a5dec2 Docs: how to update a PR moved from solidcore, and agent cost tips (#13888)
* docs: how to update a PR moved from solidcore, and agent cost tips

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

* docs: fetch the moved branch into a local branch

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:37:55 +00:00
PeppaWutz c7fb4f7cab Agents and scripts: Export describes its exact parameters and reply (#13315) (#13321)
file.export gets a typed contract (ExportParams) and an output schema
(FileExportResult), so describe_command and engine.describe give its
exact parameters and reply, and warn and strict can check its calls.

The contract reads each key only for the format the handler reads it
for (the URDF keys for URDF, quality and objects for the others), with
the handler's own messages, so a call that works today is not refused.
A format or mesh_prefix that is not text, which the handler reads as
left out, is now reported in warn and refused in strict.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 21:54:38 +00:00
jyviko 089d09e40a Navigation bar and view compass: components that return the action chosen (#13409)
* 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>

* Window chrome: the title bar and window settings from plain inputs (#12940)

window_chrome holds what any Craft app's window needs without SolveApp: viewport(title, app_id,
custom_titlebar) with SolidCraft's window settings, TitleBar (traffic-light inset, drag area,
caption buttons) that returns a WindowAction for the app to run, and the resize zones and hit
test. titlebar.rs is SolidCraft's thin use of it: Close asks about unsaved designs first.

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

* Window chrome: the macOS menu bar from the kit's menu items (#12940)

window_chrome::native_menu builds the menu bar from ui_kit::Item trees (the app menu's system
layout, the system's Window entries) and returns the chosen entry as (id, params); the app-menu
title comes from the app. SolidCraft's native_menu.rs builds its Items from FileAction with
command ids or ui.* ids and maps the ids back, with the same entries, shortcuts and labels.

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

* Window chrome: the application bar is laid out by TitleBar (#12940)

app_bar drew its own drag area, traffic-light inset and caption buttons beside the shared
TitleBar; it now runs TitleBar::show and answers the returned WindowAction through
titlebar::run, so the title bar tests pin what SolidCraft draws.

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

* Window chrome: the menu bar's layout is pure and tested on every system (#12940)

MenuTable lays out the whole bar (the app menu's system entries, the Window menu's extras,
submenus) and replaces a submenu's entries itself; NativeMenu only turns rows into AppKit
items. Nothing is dead code off macOS, and the layout and replace tests run everywhere.
The Window menu's id is menu.window; menu ids are never dispatched.

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

* Native menu: entries use the control channel's ids, tested on every system (#12940)

Quit is app.quit, Settings and Keyboard Shortcuts are ui.prefs {open}, Agent Chat is
ui.agent_chat and Help's items are ui.help {item}; the menus are menu.file, menu.file.recent,
menu.edit, menu.window and menu.help. The id round trip and the shortcut table are tested
without a window on every system; the module builds there for its tests only.

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

* Native menu: its control ids do the same over the control channel (#12940)

Agent Chat sends ui.agent_chat {toggle}, which the control channel now takes (show or hide as
the panel's button does). ui.help runs every item the Help menu sends (docs, messages and
install_cli too) and answers an error for an unknown item instead of a silent ok. A test
runs the menu's control ids through the control channel.

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

* Window chrome: a menu of the bar that is off stays off; replace is for submenus (#12940)

The bar's own menus take their item's enabled flag. replace() refuses a menu of the bar
(it keeps the system entries the layout gave it), now documented and tested.

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

* Native menu: the actions with a fixed id are a slice, not a sized array (#12940)

The list grows with the menu, so it follows the repo's rule for growing lists (cargo xtask lists).

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

* Navigation bar: the bar is a component that returns the slot or menu row chosen (#12944)

nav_bar draws the bar, More, its context menu and the open menu from plain data
(SlotView, Menu, BarLabels) and returns a NavAction; it keeps which menu is open
and reads nothing of the app. navigation_bar_view now describes SolidCraft's
slots and its Visual style and View menus as that data and runs the action it
gets back with the same calls as before. The Preferences editor keeps using
paint_row, which now paints through the component.

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

* View compass: the signed-axis compass is a component that returns the view chosen (#12944)

view_compass draws the six signed axes from the camera's view axes (plain right,
up and back vectors), the Home chip and the hover tip, and returns a ViewAction:
Snap to an axis, Home, or Orbit by this frame's drag delta. Its drag tracker
(edge warp included) is shared by the cube and the custom body, which keep
their own drawing. SolidCraft turns the camera with the same calls as before
(animate_turn with the reverse-on-repeat rule, animate_view, orbit_drag).

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

* Navigation bar: a slot's second registry name is published where it is drawn (#12944)

SlotView gains an alias the bar publishes at the slot's handle, on the bar and in
More alike. SolidCraft gives the View menu's slot its older name `nav:view` there,
instead of looking the slot's handle up after the bar has drawn.

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

* Navigation bar: repaint after running the chosen action (#12944)

A menu that stays open after a pick (View) shows its rows' new state on the next
frame instead of waiting for the next input.

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

* Navigation bar: the component's returned actions are tested from plain data (#12944)

Driven with no app: a slot and a row of its menu are returned and a closing menu
closes; More returns a folded slot and closes, opens a folded menu in its place,
and a menu that does not close on a pick stays open; a submenu row is returned by
its own id; the bar's context menu returns Customise, Hide and Reset, and a slot's
(on the bar or in More) returns Remove with the slot's stored index.

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

* View compass: plain [f64; 3] axes in and out, no geometry type (#12944)

ViewAxes, Tip and ViewAction::Snap carry plain arrays, with the dot product a
local function, so the component needs neither solidcore_record::Vec3 nor the
engine's geometry trait. The compass tip painter takes the axis as an array too
(it only reads which axis it is, for the colour); SolidCraft converts at its edge
(view_axes, the Snap arm), and the Preferences navigator figure is posed by the
same ViewAxes::pose and AXES list as the live compass.

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

* View compass: the tip reach is defined once, by the compass (#12944)

The Preferences navigator figure placed its tips with its own 38.0 * 0.84; it now
scales view_compass::TIP_REACH, so the figure and the live compass cannot drift.

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

* View compass: SolidCraft runs only the actions the compass can return (#12944)

show() returns a tip or Home, never Orbit (a drag comes from orbit()); the docs
say so and signed_axis_view no longer carries a dead Orbit arm.

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

* View compass: the module list in rustfmt's order (#12944)

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

* View compass: one function places a tip, for the compass and its figure (#12944)

tip_offset maps a posed screen direction to where its tip is drawn, applying the
reach once at f64; tips() and the Preferences navigator figure both call it, so
TIP_REACH goes. The figure's tips move by about one f32 ulp.

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

* View compass: show() returns only a tip or Home; a drag is an OrbitStep (#12944)

ViewAction is now Snap or Home, so a drag cannot be represented as something the
compass returns; orbit() returns its motion as an OrbitStep. signed_axis_view's
match is exhaustive over what the compass can return, with no catch-all arm.

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

* Navigation bar: SolidCraft's View slot is `nav:view` on the bar and in More (#12944)

A harness test: with the View slot on the bar, `nav:view` is its handle; folded
into More in a narrow view it is not shown until More opens, then `nav:view` is
the folded row's handle.

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 21:44:57 +00:00
55 changed files with 3352 additions and 803 deletions
+2 -2
View File
@@ -448,8 +448,8 @@ jobs:
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim, triage and kernel-paused bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js scripts/pr-checks/*.test.js
- name: Claim, triage and PR-check bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/pr-checks/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
-43
View File
@@ -1,43 +0,0 @@
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
# in ci.yml).
name: Kernel paused
on:
issues:
types: [opened]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
discussion:
types: [created]
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: write # to list a PR's changed paths and add its label
discussions: write
concurrency:
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
cancel-in-progress: false
jobs:
paused:
name: Kernel paused bot
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/kernel-paused
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const paused = require('./scripts/kernel-paused/kernel-paused.js');
const done = context.eventName === 'discussion'
? await paused.handleDiscussion({ github, context })
: await paused.handleOpened({ github, context });
core.info(done);
+25 -8
View File
@@ -17,15 +17,28 @@ Before choosing a task, check the [command inventory](docs/features.md) and
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Kernel work is paused
## Kernel work goes in solidcore/
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
change `crates/kernel/`, `crates/geom/` or the vendored kernel in `solidcore/`, or issues and discussions about kernel
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
including the dialogs and tools for these features.
Kernel and geometry work is welcome: booleans, fillets, chamfers, shell, offsets, sweeps and lofts,
threads and holes, STEP/IGES import and export, tessellation. It goes in `solidcore/`, the vendored
solidcore kernel, following [`solidcore/docs/layers.md`](solidcore/docs/layers.md) and
`skills/solidcraft-layers/SKILL.md`. SolidCraft's own `crates/kernel`, `crates/smlib` and
`crates/geom` only shrink toward the seam (ops, tess, record); please don't grow them.
A kernel-only pull request targets the `kernel` branch. A change that touches the kernel and the
app goes to `main` as one pull request, with a code owner approving the `solidcore/` part.
Crashes and data loss are still taken first.
### Updating a PR that was moved from solidcore
solidcore's open PRs were re-opened here on `moved/solidcore-N` branches in tangency-labs, so
contributors without write access can't push to them. To update one:
1. `git fetch https://github.com/tangency-labs/solidcraft moved/solidcore-N:moved/solidcore-N`, then
`git switch moved/solidcore-N`.
2. Commit your changes on it and push it to your fork: `git push <your-fork> moved/solidcore-N`.
3. Open a PR against `kernel` that says "Replaces #M" (M is the moved PR's number). The moved one
gets closed.
## Get it running (about 10 minutes on a fast machine)
@@ -429,6 +442,10 @@ owning one area, with a human setting direction and reporting bugs. Their workin
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
If you run an agent, keep one long-running session rather than restarting it, don't `--resume` one
whose prompt cache has gone cold (it re-reads the whole history at full price), and act on CodeRabbit
comments only when they're about crashes, wrong results, data loss or security.
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
fails when a lower-numbered open PR by someone else already closes the same issue (the label
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
+4 -4
View File
@@ -150,16 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
}
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
pub(crate) const AXIS_RADIUS: f32 = 15.0;
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens) {
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
}
/// The same compass artwork at a uniform scale, including its lettering and strokes.
#[allow(clippy::too_many_arguments)]
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d.x != 0.0 {
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d[0] != 0.0 {
t.axis_x
} else if d.y != 0.0 {
} else if d[1] != 0.0 {
t.axis_y
} else {
t.axis_z
+144 -8
View File
@@ -1163,6 +1163,9 @@ pub struct Model {
placement_local: bool,
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
held: Option<HeldSketch>,
/// The [`placement_digest`] each [`pose_only`] feature was last evaluated at, by feature id.
/// Their fingerprints leave it out, so a pose change doesn't reach the features after them.
poses: std::collections::HashMap<u64, u64>,
}
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
@@ -1479,7 +1482,10 @@ fn chain_fingerprint(
steps: &ImportDigests,
st: Option<&ModelState>,
) -> u64 {
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
// A pose-only feature's placement is checked on its own ([`Model::poses`]), so moving the
// parts it reads doesn't change the fingerprint of every feature after it (#12139).
let placed = if pose_only(f) { 0 } else { placement_digest(doc, f, st) };
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placed;
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -1793,6 +1799,70 @@ fn mirror_placements_digest(doc: &Document, f: &Feature, placements: &[Vec<u64>]
h.finish()
}
/// Whether only the bodies `f` makes follow where parts are placed: a mirror of placed bodies
/// into new bodies. A feature after it reads its copies only by naming them, so a pose change
/// need not evaluate features that don't (#12139).
fn pose_only(f: &Feature) -> bool {
matches!(&f.kind, FeatureKind::Mirror { features, bodies, placements, combine: false, .. }
if features.is_empty() && !bodies.is_empty() && !placements.is_empty())
}
/// The new bodies of pose-only `f`, evaluated again (to `state`) for its pose alone: the result
/// `old` was on the same definitions and made bodies of the same names, and nothing besides.
fn pose_moved(old: Option<&FeatureResult>, f: &Feature, fp: u64, state: &ModelState) -> Option<Vec<ModelBody>> {
let old = old.filter(|o| o.id == f.id && o.fingerprint == fp && !o.stale && !o.skipped && !o.unkept && o.error.is_none())?;
let made = |st: &ModelState| st.bodies.iter().filter(|b| b.feature == f.id).cloned().collect::<Vec<_>>();
let (before, after) = (made(&old.state), made(state));
let names = |bodies: &[ModelBody]| bodies.iter().map(|b| b.name.clone()).collect::<std::collections::BTreeSet<_>>();
let same = names(&before) == names(&after)
&& old.state.bodies.len() == state.bodies.len()
&& old.state.threads.len() == state.threads.len()
&& old.state.sheets.len() == state.sheets.len()
&& old.state.construct.len() == state.construct.len();
same.then_some(after)
}
/// `old` (a result after the moved pose-only features) with their new bodies in place of theirs.
fn with_moved(old: &Arc<ModelState>, moved: &[(u64, Vec<ModelBody>)]) -> Arc<ModelState> {
let mut st = (**old).clone();
for b in &mut st.bodies {
if let Some(new) = moved.iter().filter(|(id, _)| *id == b.feature).flat_map(|(_, bodies)| bodies).find(|n| n.name == b.name) {
*b = new.clone();
}
}
Arc::new(st)
}
/// Whether `f` (starting from `st`) reads none of the bodies of `moved`: it reads no bodies, or
/// only bodies it names or would cut or join, none of them those. Anything else (a picked face,
/// a projection, To Object, ...) is taken as reading them.
fn reads_none_of(f: &Feature, st: &ModelState, moved: &[(u64, Vec<ModelBody>)], components: &std::collections::HashMap<u64, u64>) -> bool {
if !f.face_names.is_empty() || !f.edge_names.is_empty() || !f.edge_refs.is_empty() {
return false;
}
let reads: Vec<String> = match &f.kind {
_ if placement_reuse::local_geometry(f, components) => Vec::new(),
FeatureKind::Sketch { plane, sketch } if sketch.links.is_empty() && sketch.wires.is_empty() && placement_reuse::local_plane(plane, 0) => {
Vec::new()
}
FeatureKind::Mirror { features, bodies, plane, .. } if features.is_empty() && placement_reuse::local_plane(plane, 0) => {
bodies.iter().map(|b| b.0.clone()).collect()
}
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => return false,
FeatureKind::Revolve { to: Some(_), .. } | FeatureKind::Revolve { axis: AxisRef::Model { .. }, .. } => return false,
kind if drawn_tools_change_bodies(kind) => {
let mut reads = PlacementReads::default();
reads.drawn_targets(Some(st), f);
if reads.all {
return false;
}
reads.bodies.into_iter().collect()
}
_ => return false,
};
!moved.iter().flat_map(|(_, bodies)| bodies).any(|b| reads.contains(&b.name))
}
fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64 {
// No shortcut for a design without placements: its first component (placed where the
// design is) must not change what its features read, or creating it evaluates them all.
@@ -2193,10 +2263,11 @@ impl Model {
let mut state = self.empty.clone();
doc.features.iter().zip(&self.results).enumerate().all(|(i, (f, r))| {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps, Some(&state));
let posed = !pose_only(f) || self.poses.get(&f.id) == Some(&placement_digest(doc, f, Some(&state)));
if !r.skipped {
state = r.state.clone();
}
r.id == f.id && r.fingerprint == prev
r.id == f.id && r.fingerprint == prev && posed
})
}
@@ -2264,6 +2335,10 @@ impl Model {
feature_component.entry(f.id).or_insert(f.component);
}
let watched = crate::progress::begin(doc.features.len());
let mut poses = std::collections::HashMap::new();
// Pose-only features evaluated again for a pose change alone: their new bodies, which
// replace the old ones in the results reused after them (#12139).
let mut moved: Vec<(u64, Vec<ModelBody>)> = Vec::new();
// The features that failed so far in this pass, by id: their names and errors (#9075).
let mut failed: std::collections::HashMap<u64, (String, String)> = std::collections::HashMap::new();
for (i, f) in doc.features.iter().enumerate() {
@@ -2292,7 +2367,12 @@ impl Model {
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.import_digests, Some(&state));
prev_fp = fp;
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
let pose = pose_only(f).then(|| placement_digest(doc, f, Some(&state)));
if let Some(pose) = pose {
poses.insert(f.id, pose);
}
let posed = pose.is_none_or(|pose| self.poses.get(&f.id) == Some(&pose));
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale && posed;
// A state the saved model did not keep is evaluated again from the one before it; the
// results after it that the model did keep still stand (#3220).
let unkept = reuse && self.results.get(i).filter(current).is_some_and(|old| old.unkept);
@@ -2303,14 +2383,28 @@ impl Model {
// has a result only while those picks are what they were.
if reuse
&& !unkept
&& let Some(old) = self.results.get(i).filter(current).filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
&& let Some(old) = self
.results
.get(i)
.filter(current)
.filter(|old| moved.is_empty() || old.skipped || reads_none_of(f, &state, &moved, &feature_component))
.filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
{
if !old.skipped {
state = old.state.clone();
if moved.is_empty() {
if !old.skipped {
state = old.state.clone();
}
out.push(old.clone());
} else {
if !old.skipped {
state = with_moved(&old.state, &moved);
}
out.push(FeatureResult { state: state.clone(), ..old.clone() });
}
out.push(old.clone());
continue;
}
// Reuse goes on past a pose-only feature evaluated again for its pose alone.
let reusing = reuse && !unkept && pose.is_some();
reuse = unkept && !cancelled;
if f.suppressed || rolled_back || cancelled {
out.push(FeatureResult {
@@ -2386,6 +2480,14 @@ impl Model {
}
// A kept result after a failed one would not be this evaluation's.
reuse &= c.error.is_none();
if reusing
&& c.error.is_none()
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
{
moved.retain(|(id, _)| *id != f.id);
moved.push((f.id, bodies));
reuse = true;
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -2407,8 +2509,9 @@ impl Model {
// (the same chain of definitions from the start). Changed inputs never reuse it.
if let Some(r) = rolled
.as_ref()
.filter(|_| moved.is_empty())
.filter(current)
.or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current))
.or_else(|| primed.as_ref().filter(|_| moved.is_empty()).and_then(|p| p.get(&f.id)).filter(current))
.filter(|r| r.error.is_some() || legacy_current(doc, f, &state, &r.state))
{
restored += 1;
@@ -2549,6 +2652,14 @@ impl Model {
}
}
reuse &= error.is_none();
if reusing
&& error.is_none()
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
{
moved.retain(|(id, _)| *id != f.id);
moved.push((f.id, bodies));
reuse = true;
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -2566,6 +2677,7 @@ impl Model {
});
}
self.placement_local = self.placement_local && out.iter().all(|r| !r.skipped && !r.stale && !r.unkept && r.error.is_none());
self.poses = poses;
self.results = out;
self.last_recomputed = recomputed;
self.recomputed += recomputed;
@@ -6959,6 +7071,30 @@ fn replay(
}
continue;
}
// A thread is cut again on the copy of its face. A copy keeps the thread's hand: the
// mirror of a right-hand M8 hole is a right-hand M8 hole, not a left-hand one.
if matches!(src.kind, FeatureKind::Thread { .. }) {
for m in mats {
let mut copy = (*src).clone();
copy.id = f.id;
copy.face_names.clear();
if let FeatureKind::Thread { face, from, up_to, .. } = &mut copy.kind {
face.0 = crate::apply_point(m, face.0);
if let Some(q) = from {
q.0 = crate::apply_point(m, q.0);
}
match up_to {
Some(crate::ThreadLimit::Face(q)) => q.0 = crate::apply_point(m, q.0),
Some(crate::ThreadLimit::Plane(_)) => {
return Err(DocError::Invalid(format!("{} ends at a plane, so it cannot be patterned or mirrored yet", src.name)));
}
None => {}
}
}
thread::eval(doc, vals, &copy, st, warning)?;
}
continue;
}
let (op, targets) = feature_op(src);
// A primitive's or a hole's targets are its participants (#1108): the copies change
// those bodies only, like the feature itself (#2755).
+10 -1
View File
@@ -917,7 +917,10 @@ impl Model {
}
let mut model = Model { results, param_errors, import_digests, context: ctx, frame_bodies, validation, ..Model::default() };
let unkept = model.results.iter().filter(|r| r.unkept).count();
if unkept > 0 {
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be from
// another pose than `doc`'s: evaluated again, with what reads none of it kept (#12139).
let posed = doc.features.iter().zip(&model.results).any(|(f, r)| !r.skipped && pose_only(f));
if unkept > 0 || posed {
// The features whose state the file did not keep, each evaluated again from the state
// before it: nothing else in the model is missing then.
model.evaluate(doc);
@@ -933,6 +936,12 @@ impl Model {
let mut before = Arc::new(ModelState::default());
if let Ok(mut cache) = model.cache.lock() {
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be
// from another pose than `doc`'s: it is no result for `doc`'s key (#12139).
if pose_only(f) {
before = r.state.clone();
continue;
}
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
+1 -1
View File
@@ -55,7 +55,7 @@ impl Model {
}
}
fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
pub(super) fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
if depth > crate::MAX_PLACEMENT_DEPTH {
return false;
}
+9 -2
View File
@@ -18,6 +18,7 @@ use crate::schema::features::{
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
SphereParams, TorusParams,
};
use crate::schema::split::{SplitBodyParams, SplitFaceParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -36,6 +37,8 @@ const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::n
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
const CHAMFER_CONTRACT: TypedContract<ChamferParams> = TypedContract::new();
const SPLIT_FACE_CONTRACT: TypedContract<SplitFaceParams> = TypedContract::new();
const SPLIT_BODY_CONTRACT: TypedContract<SplitBodyParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
@@ -173,15 +176,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&PLANE_AT_ANGLE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir} | {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; direction_only?: bool (default false: turn about the axis as a line; true: project the axis direction onto base and turn about the line through pivot, or the base origin); pivot?: point ref (only with direction_only; vertex, sketch point, construction point); name?"),
CommandSpec::new("solid.split_face", "Split Face", split_face)
CommandSpec::new("solid.split_face", "Split Face", crate::checked!("solid.split_face", split_face))
.at("SOLID", "MODIFY")
.icon("split_face")
.contract(&SPLIT_FACE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", split_body)
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
.at("SOLID", "MODIFY")
.icon("split_body")
.contract(&SPLIT_BODY_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
+4 -1
View File
@@ -63,9 +63,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).icon("delete").noundo().params("id, or all: true; dir?"),
CommandSpec::new("file.export", "Export", export)
CommandSpec::new("file.export", "Export", crate::checked!("file.export", export))
.icon("export")
.noundo()
.contract(&EXPORT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
// One command per format, so each File › Export item can have a key of its own; started
// from a key or command search, the app opens the save picker (the mesh dialog for STL and
@@ -128,6 +130,7 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"name": name}))
}
const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportParams> = crate::schema::TypedContract::new();
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();
+28
View File
@@ -1239,3 +1239,31 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
let far = s.world_state().unwrap().bodies.iter().map(|b| b.mesh().bounds().max.x).fold(f64::MIN, f64::max);
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
}
/// #13278: a mirror of a Thread repeats it on the mirrored hole, whether the hole was mirrored
/// earlier or is mirrored together with the thread.
#[test]
fn mirror_of_a_thread_threads_the_mirrored_hole() {
let plane = json!({"origin": [20, 0, 0], "normal": [1, 0, 0]});
for (together, modeled) in [(false, false), (true, false), (false, true), (true, true)] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "diameter": 6.8, "depth": 10, "name": "Hole1"}));
if !together {
let r = run(&mut s, "solid.mirror", json!({"features": ["Hole1"], "plane": plane}));
assert!(r["error"].is_null(), "{r}");
}
run(&mut s, "solid.thread", json!({"face": [13.4, 15, 5], "designation": "M8", "modeled": modeled, "name": "Thread1"}));
let features = if together { json!(["Hole1", "Thread1"]) } else { json!(["Thread1"]) };
let r = run(&mut s, "solid.mirror", json!({"features": features, "plane": plane}));
assert!(r["error"].is_null(), "together {together}: {r}");
let state = s.world_state().unwrap();
if modeled {
assert_eq!(state.bodies.len(), 1, "{r}");
let threaded = volume(&mut s);
assert!(threaded < 40.0 * 30.0 * 10.0 - 2.0 * std::f64::consts::PI * 3.4 * 3.4 * 10.0 - 1.0, "both holes are threaded: {threaded}");
} else {
assert_eq!(state.threads.len(), 2, "together {together}: the source and its mirror");
}
}
}
+5 -1
View File
@@ -15,6 +15,7 @@ use crate::schema::sketch::{
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
};
use crate::schema::sketch_dimension::{DimensionResult, SketchDimensionParams};
use crate::schema::sketch_dimensioned::{DrawDimensionedParams, DrawDimensionedResult};
use crate::schema::sketch_session::{
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
@@ -63,6 +64,7 @@ const DIMENSION_TEXT_CONTRACT: TypedContract<DimensionTextParams> = TypedContrac
const OPTIONS_CONTRACT: TypedContract<OptionsParams> = TypedContract::new();
const CHAIN_CONTRACT: TypedContract<ChainParams> = TypedContract::new();
const SOLVE_CONTRACT: TypedContract<SolveParams> = TypedContract::new();
const DRAW_DIMENSIONED_CONTRACT: TypedContract<DrawDimensionedParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
const HORIZONTAL_VERTICAL_CONTRACT: TypedContract<HorizontalVerticalParams> = TypedContract::new();
@@ -123,8 +125,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&LINE_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…] (x and y each a number or an expression over the design's parameters, \"W / 2\": such a point is fixed where it comes to and follows the parameters; likewise wherever a point argument also takes a point id), closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", crate::checked!("sketch.draw.dimensioned", draw_dimensioned)).icon("dimension")
.enabled(in_sketch)
.contract(&DRAW_DIMENSIONED_CONTRACT)
.output(output_schema_for::<DrawDimensionedResult>)
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value, or `name = expression` (also `#name = expression`), which defines the user parameter `name` as sketch.dimension does (the reply's `defined` lists the names) (optional with as_drawn or locate), as_drawn?: bool (also dimension every other role of the shape at its size as drawn; a radius or diameter given takes the place of the other, as an edge polygon's side length does its radius, a circle otherwise takes its diameter, and a size the sketch already determines, such as the angle of a line inferred horizontal, is left out), locate?: bool (then place the shape from the sketch origin until nothing of it is left free: horizontal and vertical distances from the origin to its points, centres first, each kept only where it removes freedom, so a shape anchored to the origin or to placed geometry gets none; a point on an axis goes onto that axis instead of a 0 distance; the reply's located lists them); creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", crate::checked!("sketch.rectangle.two_point", rect_two_point))
.at("SKETCH", "CREATE")
+7 -12
View File
@@ -17,6 +17,7 @@ use solidcraft_doc::expr::{Kind, definition};
use crate::{
Result, Session,
params::{bad, bool_, expr, str_},
schema::sketch_dimensioned::DrawDimensionedResult,
};
/// The dimension roles of a sketch creator, in the order `as_drawn` adds them, or None when
@@ -135,13 +136,13 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let mut scratch = s.scratch();
let mut out = scratch.execute(create, &params)?;
let out = scratch.execute(create, &params)?;
let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default();
let curve = |index: usize| -> Result<Value> {
curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve"))
};
let mut added = serde_json::Map::new();
let mut defined = Vec::new();
let mut defined: Vec<String> = Vec::new();
for (role, value) in wanted(roles, dimensions, as_drawn) {
let (entities, ty) = match (create, role) {
("sketch.line" | "sketch.line.midpoint", "length") => (vec![curve(0)?], "length"),
@@ -187,8 +188,8 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
dimension["value"] = value.clone();
dimension["if_redundant"] = json!("driven");
let result = scratch.execute("sketch.dimension", &dimension)?;
if let Some(name) = result.get("defined").filter(|v| v.is_string()) {
defined.push(name.clone());
if let Some(name) = result.get("defined").and_then(Value::as_str) {
defined.push(name.to_string());
}
added.insert(role.to_string(), result);
}
@@ -212,14 +213,8 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
scratch.undo.clear();
s.accept_computed(scratch);
out["dimensions"] = Value::Object(added);
if !defined.is_empty() {
out["defined"] = Value::Array(defined);
}
if locate {
out["located"] = Value::Array(located);
}
Ok(out)
let reply = DrawDimensionedResult::new(out, added, defined, locate.then_some(located));
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// The sketch's degrees of freedom as a command reported them.
+7 -1
View File
@@ -331,7 +331,13 @@ pub fn run_checked(s: &mut Session, p: &Value, id: &str, contract: &dyn Contract
}
match result {
Ok(Value::Object(mut o)) if level == ParamCheck::Warn && !issues.is_empty() => {
o.insert("param_warnings".into(), json!(issues.iter().map(|i| i.message.as_str()).collect::<Vec<_>>()));
// A command that runs another checked command keeps that one's warnings too.
let mut warnings = match o.remove("param_warnings") {
Some(Value::Array(inner)) => inner,
_ => Vec::new(),
};
warnings.extend(issues.iter().map(|i| json!(i.message)));
o.insert("param_warnings".into(), Value::Array(warnings));
Ok(Value::Object(o))
}
other => other,
+152 -6
View File
@@ -1,10 +1,14 @@
//! Contracts and result types for the commands in `cmd/file.rs`: so far the export command of
//! each format (`file.export_step`, `file.export_stl`, `file.export_3mf`, `file.export_obj`,
//! `file.export_urdf`), which take what `file.export` takes for that format, without `format`.
//! Contracts and result types for the commands in `cmd/file.rs`: so far `file.export` and the
//! export command of each format (`file.export_step`, `file.export_stl`, `file.export_3mf`,
//! `file.export_obj`, `file.export_urdf`), which take what `file.export` takes for that format,
//! without `format`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use solidcraft_io::Format;
use super::check::whole;
use super::types::StringList;
/// How closely an exported mesh follows curved faces.
@@ -93,6 +97,137 @@ pub struct ExportUrdfParams {
pub velocity: Option<f64>,
}
/// Export bodies, or the whole design, to a file. `format` names the format; without it the
/// path's extension does. STL, OBJ and 3MF take `quality` (and 3MF `objects`); URDF takes
/// `mesh_prefix`, `effort` and `velocity` and exports the whole assembly; design JSON the
/// complete document.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = export_required)]
pub struct ExportParams {
// `path` is optional in the type and required in the schema: left out, the handler refuses it
// with its own message.
/// The file to write.
#[serde(default)]
pub path: Option<String>,
/// The format: stl, stla, obj, step, 3mf, 3mf-tagged, urdf or json, or a file name whose
/// extension names it. Default: from the path's extension.
#[serde(default)]
pub format: Option<String>,
/// The bodies to export, by name. Default: every body. Design JSON and URDF export the
/// complete design, so they refuse a list.
#[serde(default)]
pub bodies: Option<StringList>,
/// How closely the mesh follows curved faces (STL, OBJ and 3MF). Default: standard. Not read
/// for URDF.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// What each object in a 3MF file holds. Default: body; `component` is refused for the other
/// formats. Not read for URDF.
#[serde(default)]
pub objects: Option<ObjectsParam>,
/// URDF only: put before each mesh file name, such as `package://robot/meshes/`. Default: none.
#[serde(default)]
pub mesh_prefix: Option<String>,
/// URDF only: each joint's effort limit, a positive number. Default: 1000.
#[serde(default)]
pub effort: Option<f64>,
/// URDF only: each joint's velocity limit, a positive number. Default: 10.
#[serde(default)]
pub velocity: Option<f64>,
}
fn export_required(schema: &mut Schema) {
super::motion::require(schema, "path");
}
/// The handler's own message for a key, whole.
fn refuse<E: serde::de::Error>(message: impl std::fmt::Display) -> E {
E::custom(whole(message))
}
/// A key the handler reads as text (`str_`): any other value but `null` is refused, where the
/// handler would read it as left out.
fn text<E: serde::de::Error>(m: &Map<String, Value>, key: &str) -> Result<Option<String>, E> {
match m.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(E::custom(format!("`{key}` must be text"))),
}
}
/// `bodies` as the handler reads it (`params::string_list`), with its messages: `null` is left out.
fn names<E: serde::de::Error>(m: &Map<String, Value>) -> Result<Option<StringList>, E> {
match m.get("bodies") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let p = Value::Object(Map::from_iter([("bodies".to_string(), v.clone())]));
match crate::params::string_list("file.export", &p, "bodies") {
Ok(list) => Ok(Some(StringList(list))),
Err(crate::EngineError::BadParams { msg, .. }) => Err(refuse(msg)),
Err(e) => Err(refuse(e)),
}
}
}
}
impl<'de> Deserialize<'de> for ExportParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = Map::<String, Value>::deserialize(d)?;
let mut out =
ExportParams { path: None, format: None, bodies: None, quality: None, objects: None, mesh_prefix: None, effort: None, velocity: None };
// The handler reads `path` first and refuses anything but a name it can write to; the
// other keys are read only once it has one, and the format is known.
out.path = match m.get("path") {
None => return Ok(out),
Some(Value::String(s)) if !s.trim().is_empty() && s.len() < 4096 => Some(s.clone()),
Some(_) => return Err(refuse::<D::Error>("`path` is required")),
};
out.format = text::<D::Error>(&m, "format")?;
let Some(format) = out.format.as_deref().or(out.path.as_deref()).and_then(|n| Format::from_name(n).ok()) else {
// The handler refuses a format it does not know before it reads anything else.
return Ok(out);
};
out.bodies = names::<D::Error>(&m)?;
if format == Format::Urdf {
// URDF reads its own keys; `quality` and `objects` are not read.
out.mesh_prefix = text::<D::Error>(&m, "mesh_prefix")?;
let positive = |key: &str| -> Result<Option<f64>, D::Error> {
match m.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && x.abs() < 1e9 && *x > 0.0)
.map(Some)
.ok_or_else(|| refuse(format!("`{key}` must be a positive number"))),
}
};
out.effort = positive("effort")?;
out.velocity = positive("velocity")?;
return Ok(out);
}
// `mesh_prefix`, `effort` and `velocity` are not read for the other formats.
out.quality = match m.get("quality") {
None | Some(Value::Null) => None,
Some(v) => Some(match v.as_str().and_then(solidcraft_io::MeshQuality::from_name) {
Some(solidcraft_io::MeshQuality::Standard) => MeshQualityParam::Standard,
Some(solidcraft_io::MeshQuality::Fine) => MeshQualityParam::Fine,
Some(solidcraft_io::MeshQuality::Finest) => MeshQualityParam::Finest,
None => return Err(refuse(format!("`quality` must be standard, fine or finest, not {v}"))),
}),
};
out.objects = match m.get("objects") {
None | Some(Value::Null) => None,
Some(v) => match v.as_str() {
Some("body") => Some(ObjectsParam::Body),
Some("component") if matches!(format, Format::ThreeMf | Format::ThreeMfTagged) => Some(ObjectsParam::Component),
Some("component") => return Err(refuse("`objects: component` is for 3MF: other formats have no parts")),
_ => return Err(refuse(format!("`objects` must be body or component, not {v}"))),
},
};
Ok(out)
}
}
/// What `file.export_step`, `file.export_stl`, `file.export_3mf` and `file.export_obj` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportResult {
@@ -100,7 +235,8 @@ pub struct ExportResult {
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: Step, StlBinary, ThreeMf or Obj.
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` also StlAscii,
/// ThreeMfTagged or Design).
pub format: String,
/// OBJ only: every file written, the material library first and the mesh file last.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -130,6 +266,16 @@ pub struct UrdfExportResult {
pub param_warnings: Vec<String>,
}
/// What `file.export` returns: what the export command of the format returns.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum FileExportResult {
/// STL, OBJ, STEP, 3MF or design JSON.
File(ExportResult),
/// URDF.
Urdf(UrdfExportResult),
}
#[cfg(test)]
#[path = "file_tests.rs"]
mod tests;
+150 -4
View File
@@ -1,9 +1,9 @@
use serde_json::json;
use serde_json::{Value, json};
use super::{Export3mfParams, ExportMeshParams, ExportStepParams, ExportUrdfParams};
use super::{Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportUrdfParams, FileExportResult};
use crate::Session;
use crate::schema::tests::assert_keys;
use crate::schema::{ParamCheck, input_schema_for};
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
@@ -11,6 +11,7 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ExportMeshParams>(), &["bodies", "path", "quality"]);
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "objects", "path", "quality"]);
assert_keys(input_schema_for::<ExportUrdfParams>(), &["effort", "mesh_prefix", "path", "velocity"]);
assert_keys(input_schema_for::<ExportParams>(), &["bodies", "effort", "format", "mesh_prefix", "objects", "path", "quality", "velocity"]);
}
#[test]
@@ -36,3 +37,148 @@ fn bad_calls_say_what_is_wrong_and_write_nothing() {
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), 0, "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
/// A fresh folder for one test's files.
fn folder(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-export-schema-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// 10 mm cubes in components A (grounded) and B, so a URDF and a 3MF with parts can be written.
fn two_cubes() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [x, 0, 10], "operation": "new"}));
}
let a = s.doc.find_component("A").and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id).unwrap_or(0);
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
s
}
#[test]
fn strict_keeps_todays_errors() {
let dir = folder("strict");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
assert_strict_keeps_todays_errors(&[
("file.export", json!({})),
("file.export", json!({"path": ""})),
("file.export", json!({"path": " "})),
("file.export", json!({"path": 5})),
("file.export", json!({"path": null})),
("file.export", json!({"path": "x".repeat(4096)})),
("file.export", json!({"path": at("a.xyz")})),
("file.export", json!({"path": at("a.stl"), "format": "xyz"})),
("file.export", json!({"path": at("a.xyz"), "quality": "rough"})),
("file.export", json!({"path": at("a.stl"), "quality": "rough"})),
("file.export", json!({"path": at("a.stl"), "quality": 3})),
("file.export", json!({"path": at("a.stl"), "objects": "component"})),
("file.export", json!({"path": at("a.3mf"), "objects": "parts"})),
("file.export", json!({"path": at("a.stl"), "bodies": [1]})),
("file.export", json!({"path": at("a.stl"), "bodies": 1})),
("file.export", json!({"path": at("a.json"), "bodies": ["Body1"]})),
("file.export", json!({"path": at("a.urdf"), "bodies": ["Body1"]})),
("file.export", json!({"path": at("a.urdf"), "effort": -1})),
("file.export", json!({"path": at("a.urdf"), "effort": 0})),
("file.export", json!({"path": at("a.urdf"), "velocity": "fast"})),
("file.export", json!({"path": at("a"), "format": "urdf", "velocity": 1e9})),
]);
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), 0, "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
/// Each format's keys are read only for that format, as the handler reads them: a key another
/// format takes is not checked, and `format` picks the format before the path's extension does.
#[test]
fn the_export_type_reads_each_key_only_for_its_format() {
let read = |p: Value| serde_json::from_value::<ExportParams>(p).map_err(|e| e.to_string());
let ok = |p: Value| read(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
let p = ok(json!({"path": "a.urdf", "quality": "rough", "objects": 3, "effort": 5}));
assert!(p.quality.is_none() && p.objects.is_none() && p.effort == Some(5.0), "{p:?}");
let p = ok(json!({"path": "a.stl", "effort": -1, "velocity": "fast", "mesh_prefix": 3, "quality": " Fine "}));
assert!(p.effort.is_none() && p.velocity.is_none() && p.mesh_prefix.is_none() && p.quality.is_some(), "{p:?}");
assert!(ok(json!({"path": "a.stl", "format": "3mf", "objects": "component"})).objects.is_some());
assert!(read(json!({"path": "a.3mf", "format": "stl", "objects": "component"})).is_err());
assert!(read(json!({"path": "a", "format": "urdf", "effort": 0})).is_err());
// An unknown format, or no path to write to, is the handler's to refuse: nothing else is read.
assert!(ok(json!({"path": "a.xyz", "quality": "rough", "effort": -1})).quality.is_none());
assert!(ok(json!({"quality": "rough"})).path.is_none());
assert!(ok(json!({"path": "a.stl", "bodies": null, "format": null, "quality": null, "objects": null})).bodies.is_none());
}
/// (`conforms` does not read `oneOf`, so `quality` and `objects` values are checked by the type,
/// in `the_export_type_reads_each_key_only_for_its_format`.)
#[test]
fn the_export_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<ExportParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.3mf", "format": "3mf-tagged", "bodies": ["A", "B"], "quality": "fine", "objects": "component"}), true),
(json!({"path": "robot.urdf", "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2.5}), true),
(json!({"path": "a.step", "bodies": "Body1"}), true),
(json!({}), false),
(json!({"path": "a.stl", "bodies": [1]}), false),
(json!({"path": "a.stl", "colour": true}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// Real exports of every format in warn: no `param_warnings`, and each reply matches the output
/// schema. The same calls go through in strict.
#[test]
fn export_replies_match_the_output_schema() {
let dir = folder("replies");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
let schema = output_schema_for::<FileExportResult>();
let schema = schema.as_value();
let calls = [
json!({"path": at("a.stl")}),
json!({"path": at("b.stl"), "format": "stla", "quality": "finest"}),
json!({"path": at("c.obj"), "quality": "fine"}),
json!({"path": at("d.step")}),
json!({"path": at("e.3mf"), "objects": "component"}),
json!({"path": at("f.bin"), "format": "3mf-tagged"}),
json!({"path": at("g.json")}),
json!({"path": at("h.urdf"), "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2}),
];
for level in [ParamCheck::Warn, ParamCheck::Strict] {
let mut s = two_cubes();
s.param_check = level;
for p in &calls {
let reply = run(&mut s, "file.export", p.clone());
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(conforms(schema, schema, &reply), "{p}: {reply} doesn't match {schema}");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A misspelt key is reported in warn and refused in strict, before anything is written; a key of
/// the wrong type the handler reads as left out is reported too.
#[test]
fn a_misspelt_or_wrong_typed_export_key_is_reported() {
let dir = folder("misspelt");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
let mut s = two_cubes();
s.param_check = ParamCheck::Warn;
let reply = run(&mut s, "file.export", json!({"path": at("a.stl"), "qualty": "fine"}));
let warnings = reply["param_warnings"].to_string();
assert!(warnings.contains("qualty"), "{reply}");
let reply = run(&mut s, "file.export", json!({"path": at("b.stl"), "format": 3}));
assert!(reply["param_warnings"].to_string().contains("format"), "{reply}");
s.param_check = ParamCheck::Strict;
let e = s.execute("file.export", &json!({"path": at("c.stl"), "qualty": "fine"})).unwrap_err().to_string();
assert!(e.contains("qualty"), "{e}");
assert!(!dir.join("c.stl").exists(), "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
+2
View File
@@ -115,6 +115,7 @@ pub mod sketch_clipboard;
pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_dimension;
pub mod sketch_dimensioned;
pub mod sketch_ends;
pub mod sketch_equation;
pub mod sketch_import;
@@ -123,6 +124,7 @@ pub mod sketch_modify;
pub mod sketch_project;
pub mod sketch_session;
pub mod sketch_shapes;
pub mod split;
pub mod surface;
pub mod surface_analysis;
pub mod surface_boundary;
@@ -0,0 +1,278 @@
//! Contract and result type for `sketch.draw.dimensioned` in `cmd/sketch_dimensioned.rs` (#13313).
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler
//! reads it and fails with the handler's message. `command` and `params` are optional in the
//! type and required in the schema, so that the handler's message, not the check's "is required",
//! is what a caller sees when they are left out. Which roles `dimensions` may name depends on the
//! creator, and whether a value can be evaluated or is in range depends on the design: both stay
//! the handler's to check. A call with several problems may get a different one first in strict
//! than with the check off (the check goes key by key, the handler in its own order).
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value, json};
use super::check::whole;
use super::motion::require;
use super::types::{AngleExpr, LengthExpr};
use crate::params;
/// The sketch creators `sketch.draw.dimensioned` can dimension: those `dimension_roles` lists.
pub const CREATORS: &[&str] = &[
"sketch.line",
"sketch.line.midpoint",
"sketch.rectangle.two_point",
"sketch.rectangle.center",
"sketch.rectangle.three_point",
"sketch.rectangle.rounded",
"sketch.circle.center",
"sketch.circle.two_point",
"sketch.circle.three_point",
"sketch.arc.center_point",
"sketch.ellipse",
"sketch.slot.center_to_center",
"sketch.slot.overall",
"sketch.slot.center_point",
"sketch.slot.arc_center",
"sketch.slot.arc_three_point",
"sketch.polygon.inscribed",
"sketch.polygon.circumscribed",
"sketch.polygon.edge",
];
/// A sketch creator as `command` names it: one `dimension_roles` knows.
#[derive(Clone, Debug, PartialEq)]
pub struct Creator(pub String);
impl<'de> Deserialize<'de> for Creator {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if crate::dimension_roles(&s).is_some() => Ok(Creator(s)),
Value::String(_) => Err(D::Error::custom(whole("unsupported sketch creator"))),
_ => Err(D::Error::custom(whole("`command` must name a supported sketch creator"))),
}
}
}
impl JsonSchema for Creator {
fn schema_name() -> Cow<'static, str> {
"Creator".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A sketch creation command whose shape can be dimensioned.",
"type": "string",
"enum": CREATORS
})
}
}
/// The creator's own parameters: an object (`null` is refused).
#[derive(Clone, Debug, PartialEq)]
pub struct CreatorParams(pub Map<String, Value>);
impl<'de> Deserialize<'de> for CreatorParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::Object(o) => Ok(CreatorParams(o)),
_ => Err(D::Error::custom(whole("`params` must be an object"))),
}
}
}
impl JsonSchema for CreatorParams {
fn schema_name() -> Cow<'static, str> {
"CreatorParams".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The parameters of the creator `command` names, as that command takes them. The creator checks them.",
"type": "object"
})
}
}
/// The sizes to place, by role. Each value is a number or an expression of at most 4096
/// characters, as `params::expr` reads it (`null` is refused).
#[derive(Clone, Debug, PartialEq)]
pub struct DimensionValues(pub Map<String, Value>);
impl<'de> Deserialize<'de> for DimensionValues {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let Value::Object(o) = Value::deserialize(d)? else {
return Err(D::Error::custom(whole("`dimensions` must be an object")));
};
for value in o.values() {
if params::expr(&json!({"value": value}), "value").is_none_or(|e| e.len() > 4096) {
return Err(D::Error::custom(whole("dimension values must be bounded expressions or finite numbers")));
}
}
Ok(DimensionValues(o))
}
}
impl JsonSchema for DimensionValues {
fn schema_name() -> Cow<'static, str> {
"DimensionValues".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let length = generator.subschema_for::<LengthExpr>();
let angle = generator.subschema_for::<AngleExpr>();
json_schema!({
"description": "The sizes to place, each by its role: a number (in the design's unit) or an expression, or `name = expression`, which also defines the user parameter `name`. The roles a creator takes are its own: a line or midpoint line takes `length` and `angle`; a rectangle `width` and `height`; a circle `diameter` or `radius` (not both); an arc by centre `radius` and `sweep`; an ellipse `major_radius` and `minor_radius`; a centre-to-centre, overall or centre-point slot `length` and `width`; a centre arc slot `radius`, `sweep` and `width`; a three-point arc slot `width`; an inscribed or circumscribed polygon `radius`; an edge polygon `radius` or `length` (not both). A role the creator does not take is refused.",
"type": "object",
"properties": {
"length": length,
"width": length,
"height": length,
"diameter": length,
"radius": length,
"major_radius": length,
"minor_radius": length,
"angle": angle,
"sweep": angle
},
"additionalProperties": length
})
}
}
/// Draw a shape and dimension it in one undo step. `command` names the sketch creator and
/// `params` its parameters; `dimensions` gives the sizes to place, by role, as driving
/// dimensions. With `as_drawn` every other size of the shape is placed at its size as drawn
/// (a size the sketch already determines is left out); with `locate` the shape is then placed
/// from the sketch origin with horizontal and vertical distances, only where each takes freedom
/// away. Either one makes `dimensions` optional. A size the sketch already has is placed as a
/// driven (reference) dimension rather than refused.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = draw_required)]
pub struct DrawDimensionedParams {
/// The sketch creation command to run: one of the listed creators.
#[serde(default, deserialize_with = "command")]
#[schemars(with = "Creator")]
pub command: Option<Creator>,
/// The creator's own parameters, as it takes them (`sketch` among them, to draw in a sketch
/// other than the active one). A `sweep` dimension replaces the creator's `sweep` and removes
/// its `end`.
#[serde(default, deserialize_with = "creator_params")]
#[schemars(with = "CreatorParams")]
pub params: Option<CreatorParams>,
/// The sizes to place, by role. Required unless `as_drawn` or `locate` is true.
#[serde(default, deserialize_with = "dimension_values")]
#[schemars(with = "DimensionValues")]
pub dimensions: Option<DimensionValues>,
/// Also dimension every other size of the shape at its size as drawn. A radius or diameter
/// given takes the place of the other (as an edge polygon's side length does its radius); a
/// circle otherwise takes its diameter. Default: false.
#[serde(default)]
pub as_drawn: Option<bool>,
/// Then place the shape from the sketch origin until nothing of it is left free: horizontal
/// and vertical distances from the origin to its points, centres first, each kept only where
/// it removes freedom, so a shape anchored to the origin or to placed geometry gets none. A
/// point on an axis goes onto that axis instead of getting a distance of 0. Default: false.
#[serde(default)]
pub locate: Option<bool>,
}
fn command<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Creator>, D::Error> {
Creator::deserialize(d).map(Some)
}
fn creator_params<'de, D: Deserializer<'de>>(d: D) -> Result<Option<CreatorParams>, D::Error> {
CreatorParams::deserialize(d).map(Some)
}
fn dimension_values<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DimensionValues>, D::Error> {
DimensionValues::deserialize(d).map(Some)
}
/// `command` and `params` are always needed; `dimensions` unless `as_drawn` or `locate` is true.
fn draw_required(schema: &mut Schema) {
require(schema, "command");
require(schema, "params");
schema.insert(
"anyOf".into(),
json!([
{"required": ["dimensions"]},
{"required": ["as_drawn"], "properties": {"as_drawn": {"enum": [true]}}},
{"required": ["locate"], "properties": {"locate": {"enum": [true]}}}
]),
);
}
/// What `sketch.draw.dimensioned` returns: what the creator returned, then the dimensions placed.
#[derive(Debug, Serialize, JsonSchema)]
#[schemars(transform = creator_reply)]
pub struct DrawDimensionedResult {
/// What the creator returned, as that command returns it (`curves`, `constraints`, `sketch`,
/// `created`, and for some creators `sides`, `corners`, `centre_arc` or `support_circle`).
#[serde(flatten)]
pub drawn: Map<String, Value>,
/// The `sketch.dimension` reply of each dimension placed, by role.
pub dimensions: Map<String, Value>,
/// The names of the user parameters a typed `name = expression` defined. Only when there
/// are any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub defined: Vec<String>,
/// With `locate`: the `sketch.dimension` or `sketch.constraint.coincident` replies of what
/// placed the shape, in order (empty when nothing was left to place).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub located: Option<Vec<Value>>,
/// In the warn parameter-check mode: what the call's parameters got wrong. When the call
/// itself is fine, the creator's own warnings about `params` are kept here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
impl DrawDimensionedResult {
/// The creator's reply `drawn` with the dimensions placed. A key the creator's reply and this
/// result both have takes this result's value, as the handler always set them over it.
pub fn new(drawn: Value, dimensions: Map<String, Value>, defined: Vec<String>, located: Option<Vec<Value>>) -> Self {
let mut drawn = match drawn {
Value::Object(o) => o,
_ => Map::new(),
};
let mut param_warnings = Vec::new();
if let Some(Value::Array(warnings)) = drawn.get("param_warnings")
&& warnings.iter().all(Value::is_string)
{
param_warnings = warnings.iter().filter_map(|w| w.as_str().map(str::to_string)).collect();
drawn.remove("param_warnings");
}
drawn.remove("dimensions");
if !defined.is_empty() {
drawn.remove("defined");
}
if located.is_some() {
drawn.remove("located");
}
DrawDimensionedResult { drawn, dimensions, defined, located, param_warnings }
}
}
/// The keys most creators' replies have, which depend on the creator (none is always there).
fn creator_reply(schema: &mut Schema) {
let strings = json!({"type": "array", "items": {"type": "string"}});
let properties = json!({
"curves": strings,
"constraints": strings,
"sketch": {"type": "object", "description": "The sketch's solve: `{solved, dof}`."},
"created": {"type": "object", "description": "What the creator added: `{points, curves, constraints, dimensions}`."},
"sides": {"type": "object", "description": "A rectangle's sides {bottom, right, top, left}, line ids."},
"corners": {"type": "object", "description": "A rectangle's corners {bottom_left, bottom_right, top_right, top_left}, point ids."},
"centre_arc": {"type": "string", "description": "A centre arc slot's middle arc."},
"support_circle": {"type": "string", "description": "A polygon's construction circle."}
});
if let (Some(Value::Object(existing)), Value::Object(extra)) = (schema.get_mut("properties"), properties) {
for (k, v) in extra {
existing.entry(k).or_insert(v);
}
}
}
#[cfg(test)]
#[path = "sketch_dimensioned_tests.rs"]
mod tests;
@@ -0,0 +1,234 @@
use serde_json::{Value, json};
use super::{CREATORS, DrawDimensionedParams, DrawDimensionedResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
const ID: &str = "sketch.draw.dimensioned";
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn scene(level: ParamCheck) -> Session {
let mut s = Session { param_check: ParamCheck::Off, ..Session::default() };
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s.param_check = level;
s
}
fn matches(reply: &Value) {
let schema = output_schema_for::<DrawDimensionedResult>();
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
assert!(reply.get("param_warnings").is_none(), "{reply}");
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<DrawDimensionedParams>(), &["as_drawn", "command", "dimensions", "locate", "params"]);
}
/// The creators in the schema are exactly the ones `dimension_roles` knows.
#[test]
fn the_creator_list_is_the_one_dimension_roles_has() {
for c in crate::cmd::command_specs() {
assert_eq!(crate::dimension_roles(c.id).is_some(), CREATORS.contains(&c.id), "{}", c.id);
}
for creator in CREATORS {
assert!(crate::cmd::find_command(creator).is_some(), "{creator}");
assert!(crate::dimension_roles(creator).is_some(), "{creator}");
}
}
#[test]
fn strict_keeps_todays_errors() {
let line = json!({"points": [[0, 0], [10, 0]]});
assert_strict_keeps_todays_errors(&[
(ID, json!({})),
(ID, json!({"command": null, "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": 5, "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.point", "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "", "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": null, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": [1], "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": line})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": null})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": null, "locate": true})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": [1]})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": null}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": true}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": ""}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": "x".repeat(4097)}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": [5]}})),
]);
}
#[test]
fn the_check_names_what_is_wrong_with_a_key() {
let said = |p: Value| issues(ID, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
let line = json!({"points": [[0, 0], [10, 0]]});
assert_eq!(said(json!({"command": "sketch.point", "params": line})), ["unsupported sketch creator"]);
assert_eq!(said(json!({"command": 3, "params": line})), ["`command` must name a supported sketch creator"]);
assert_eq!(said(json!({"command": "sketch.line", "params": 1})), ["`params` must be an object"]);
assert_eq!(said(json!({"command": "sketch.line", "params": line, "dimensions": 1})), ["`dimensions` must be an object"]);
assert_eq!(
said(json!({"command": "sketch.line", "params": line, "dimensions": {"length": []}})),
["dimension values must be bounded expressions or finite numbers"]
);
assert_eq!(said(json!({"command": "sketch.line", "params": line, "dimensions": {"length": 5}, "lenght": 1})).len(), 1);
// Left out, the handler's message is what a caller gets, not the check's.
assert!(said(json!({})).is_empty());
}
/// The types read each key as the handler does, and the schema takes what the handler takes.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<DrawDimensionedParams>();
let schema = schema.as_value();
let line = json!({"points": [[0, 0], [10, 0]]});
for (p, takes) in [
(json!({"command": "sketch.line", "params": line, "dimensions": {"length": 5, "angle": "30 deg"}}), true),
(json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}, "dimensions": {"diameter": "width = 20"}}), true),
(json!({"command": "sketch.line", "params": line, "as_drawn": true}), true),
(json!({"command": "sketch.line", "params": line, "locate": true}), true),
(json!({"command": "sketch.line", "params": line, "as_drawn": false, "locate": true}), true),
(json!({"command": "sketch.line", "params": line}), false),
(json!({"command": "sketch.line", "params": line, "as_drawn": false}), false),
(json!({"params": line, "as_drawn": true}), false),
(json!({"command": "sketch.line", "as_drawn": true}), false),
(json!({"command": "sketch.point", "params": line, "as_drawn": true}), false),
(json!({"command": "sketch.line", "params": line, "dimensions": {"length": []}}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
// The type agrees with the schema about the keys it reads.
if takes {
assert!(issues(ID, &p).is_empty(), "{p}");
}
}
}
/// Every creator, in every form the tests use (typed, as drawn, located), in Warn: the reply
/// matches the output schema and carries no warning, so the contract accepts what works today.
#[test]
fn replies_match_the_output_schema() {
let calls = [
("sketch.line", json!({"points": [[0, 0], [10, 3]]}), json!({"length": 12, "angle": "20"})),
("sketch.line.midpoint", json!({"mid": [3, 4], "end": [9, 12]}), json!({"length": 12})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 5]}), json!({"width": 20, "height": "h = 8"})),
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [5, 5]}), json!({"width": 20})),
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [20, 10], "radius": 2}), json!({"width": 30})),
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"diameter": 20})),
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"radius": "r = 4"})),
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 6], "minor_radius": 4}), json!({"major_radius": 12})),
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 5, "sides": 6}), json!({"radius": 8})),
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 6}), json!({"length": 7})),
];
for (command, params, dimensions) in calls {
for extra in [json!({}), json!({"as_drawn": true}), json!({"as_drawn": true, "locate": true}), json!({"locate": true})] {
let mut s = scene(ParamCheck::Warn);
let mut p = json!({"command": command, "params": params, "dimensions": dimensions});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
let reply = s.execute(ID, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
matches(&reply);
assert!(reply["dimensions"].is_object(), "{reply}");
assert_eq!(reply.get("located").is_some(), p.get("locate") == Some(&json!(true)), "{reply}");
}
}
// Typed values alone (no flags), a definition and `locate`: `defined` and `located` appear.
let mut s = scene(ParamCheck::Warn);
let reply = run(
&mut s,
ID,
json!({"command": "sketch.circle.center", "params": {"center": [3, 4], "radius": 3}, "dimensions": {"diameter": "w = 20"}, "locate": true}),
);
matches(&reply);
assert_eq!(reply["defined"], json!(["w"]), "{reply}");
assert!(reply["located"].as_array().is_some_and(|l| !l.is_empty()), "{reply}");
}
/// A drawing for each creator, as the handler's own tests use.
fn sample(creator: &str) -> Value {
match creator {
"sketch.line" => json!({"points":[[0,0],[6,8]]}),
"sketch.line.midpoint" => json!({"mid":[3,4],"end":[9,12]}),
"sketch.rectangle.two_point" => json!({"p0":[0,0],"p1":[30,20]}),
"sketch.rectangle.center" => json!({"center":[0,0],"corner":[15,10]}),
"sketch.rectangle.three_point" => json!({"p0":[0,0],"p1":[30,10],"p2":[25,25]}),
"sketch.rectangle.rounded" => json!({"p0":[0,0],"p1":[30,20],"radius":3}),
"sketch.circle.center" => json!({"center":[0,0],"radius":5}),
"sketch.circle.two_point" => json!({"p0":[0,0],"p1":[10,0]}),
"sketch.circle.three_point" => json!({"p0":[0,0],"p1":[10,0],"p2":[5,5]}),
"sketch.arc.center_point" => json!({"center":[0,0],"start":[10,0],"end":[0,10]}),
"sketch.ellipse" => json!({"center":[0,0],"major":[10,6],"minor_radius":4}),
"sketch.slot.center_to_center" | "sketch.slot.overall" => json!({"p0":[2,3],"p1":[22,3],"width":4}),
"sketch.slot.center_point" => json!({"center":[12,3],"end":[22,3],"width":4}),
"sketch.slot.arc_center" => json!({"center":[0,0],"start":[20,0],"end":[0,20],"width":6}),
"sketch.slot.arc_three_point" => json!({"start":[20,0],"through":[14.142136,14.142136],"end":[0,20],"width":6}),
"sketch.polygon.inscribed" | "sketch.polygon.circumscribed" => json!({"center":[4,5],"radius":10,"sides":6}),
"sketch.polygon.edge" => json!({"p0":[0,0],"p1":[10,0],"sides":6}),
other => panic!("{other}: add a sample drawing for it"),
}
}
/// Every creator, as drawn and located, in Warn: a reply that matches, with no warning.
#[test]
fn every_creator_replies_as_drawn_and_located() {
for creator in CREATORS {
for flags in [json!({"as_drawn": true}), json!({"locate": true}), json!({"as_drawn": true, "locate": true})] {
let mut s = scene(ParamCheck::Warn);
let mut p = json!({"command": creator, "params": sample(creator)});
p.as_object_mut().into_iter().for_each(|o| o.extend(flags.as_object().cloned().unwrap_or_default()));
let reply = s.execute(ID, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
matches(&reply);
assert!(reply["dimensions"].is_object() && reply["curves"].is_array(), "{reply}");
}
}
}
#[test]
fn every_creator_passes_the_check() {
for creator in CREATORS {
// The creator refuses empty parameters in its own words; the check says nothing.
assert!(issues(ID, &json!({"command": creator, "params": {}, "as_drawn": true})).is_empty(), "{creator}");
}
}
/// A misspelt key still runs in Warn and says so, with a reply that matches; strict refuses it.
#[test]
fn a_misspelt_key_is_reported_in_warn() {
let p = json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3}, "dimensions": {"diameter": 10}, "as_draw": true});
let mut s = scene(ParamCheck::Warn);
let reply = run(&mut s, ID, p.clone());
assert!(reply["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{reply}");
let schema = output_schema_for::<DrawDimensionedResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
assert!(scene(ParamCheck::Strict).execute(ID, &p).is_err());
}
/// A misspelt key of the creator and one of the call itself are both reported in Warn.
#[test]
fn warn_keeps_the_creators_warnings_beside_its_own() {
let p = json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3, "constructon": true}, "dimensions": {"diameter": 10}, "as_draw": true});
let reply = run(&mut scene(ParamCheck::Warn), ID, p);
let warnings = reply["param_warnings"].as_array().unwrap_or_else(|| panic!("{reply}"));
assert_eq!(warnings.len(), 2, "{reply}");
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("constructon"))), "{reply}");
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("as_draw"))), "{reply}");
}
/// The reply is what the handler returned before: the creator's keys, `dimensions`, `defined`,
/// `located`, and the creator's own `param_warnings` kept.
#[test]
fn the_reply_keeps_the_creators_keys() {
let creator = json!({"curves": ["c1"], "sketch": {"solved": true, "dof": 1}, "param_warnings": ["a"], "dimensions": "old", "defined": ["x"]});
let none = DrawDimensionedResult::new(creator.clone(), serde_json::Map::new(), Vec::new(), None);
let json = serde_json::to_value(&none).unwrap_or_default();
assert_eq!(json, json!({"curves": ["c1"], "sketch": {"solved": true, "dof": 1}, "param_warnings": ["a"], "dimensions": {}, "defined": ["x"]}));
let all = DrawDimensionedResult::new(creator, serde_json::Map::new(), vec!["w".into()], Some(vec![json!(1)]));
let json = serde_json::to_value(&all).unwrap_or_default();
assert_eq!(json["defined"], json!(["w"]));
assert_eq!(json["located"], json!([1]));
}
+116
View File
@@ -0,0 +1,116 @@
//! Contracts for Split Body and Split Face in `cmd/features.rs` (#13127). Both reply with the plain
//! feature reply, [`super::features::FeatureResult`].
//!
//! Each takes one cutting tool, and the handler reads the first it finds in this order: a face of
//! a body (`tool`), a sketch, then a plane. A key that is `null` counts as not given. The
//! contracts check the form of every tool key a call gives, so in strict a call that gives a good
//! `tool` and a malformed `plane` is refused, where today the plane is ignored. A call that gives
//! no tool is the handler's to refuse, with its message for a missing `plane`; whether a body,
//! sketch, line or plane exists is the handler's to check too.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use super::check::whole;
use super::types::{FaceRefs, FeatureCommon, PlaneParam, SketchRef, ToolFace, feature_common};
/// Cut a body into pieces along a plane, a face of another body (its surface extended), or a
/// straight sketch line (along the sketch's normal). Each piece becomes a body.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SplitBodyParams {
/// The body to split, by name.
#[serde(deserialize_with = "body")]
pub body: String,
/// A face of a body to split along, its surface extended. Read when neither `sketch` nor
/// `line` is given.
#[serde(default)]
pub tool: Option<ToolFace>,
/// With `line`: the sketch, by feature id (not its name), whose line splits the body.
#[serde(default)]
pub sketch: Option<SketchFeatureId>,
/// With `sketch`: a straight line's id; the body is split by the plane through it along the
/// sketch's normal, which follows the line when the sketch changes.
#[serde(default, deserialize_with = "line")]
pub line: Option<String>,
/// The plane to split across. Read when none of `tool`, `sketch` and `line` is given; then it
/// is required.
#[serde(default)]
pub plane: Option<PlaneParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Divide faces of a body along a plane, a face of another body (its surface extended), or a
/// sketch's curves (along the sketch's normal), without cutting the body: each face becomes
/// several, and the body keeps its shape.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SplitFaceParams {
/// The faces to split.
pub faces: FaceRefs,
/// The body the faces are on, by name: where faces of several bodies meet at a point, whose
/// face it is. Default: the body each point or name finds.
#[serde(default)]
pub body: Option<String>,
/// A face of a body to split along, its surface extended.
#[serde(default)]
pub tool: Option<ToolFace>,
/// A sketch, by id or name, whose curves split the faces along its normal. Read when no
/// `tool` is given.
#[serde(default)]
pub sketch: Option<SketchRef>,
/// The plane to split along. Read when neither `tool` nor `sketch` is given; then it is
/// required.
#[serde(default)]
pub plane: Option<PlaneParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Split Body's `body`, with the handler's message for anything that isn't text.
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`body` is required"))),
}
}
/// Split Body's `line`, with the handler's message for anything that isn't text. `null` is no
/// line, as the handler reads it.
fn line<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::String(s) => Ok(Some(s)),
_ => Err(D::Error::custom(whole("`line` must name a line of the sketch"))),
}
}
/// Split Body's `sketch`: a sketch's feature id, a whole number from 0 (read with
/// `Value::as_u64`). Unlike Split Face's [`SketchRef`], a name is refused, with the handler's
/// message. Whether a sketch has that id is the handler's to check.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SketchFeatureId(pub u64);
impl<'de> Deserialize<'de> for SketchFeatureId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_u64().map(SketchFeatureId).ok_or_else(|| D::Error::custom(whole("`sketch` must be a sketch feature id")))
}
}
impl JsonSchema for SketchFeatureId {
fn schema_name() -> Cow<'static, str> {
"SketchFeatureId".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A sketch's feature id.", "type": "integer", "minimum": 0, "maximum": u64::MAX})
}
}
#[cfg(test)]
#[path = "split_tests.rs"]
mod tests;
+210
View File
@@ -0,0 +1,210 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::{SplitBodyParams, SplitFaceParams};
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<SplitBodyParams>(),
&["body", "body_name", "body_names", "line", "name", "participants", "plane", "sketch", "tool"],
);
assert_keys(
input_schema_for::<SplitFaceParams>(),
&["body", "body_name", "body_names", "faces", "name", "participants", "plane", "sketch", "tool"],
);
}
/// The design the split calls work on, at `level`, and the sketch ids `Cuts` and `Ring`:
/// - `B`, a 40 x 30 x 20 mm box from the origin (its top at z = 20);
/// - `Tool`, a box whose face at y = 10, extended, crosses `B`'s top (the point [20, 10, 27] is
/// on it);
/// - the sketch `Cuts` on XY, two lines across `B` at x = 10 (id `cut`) and x = 30;
/// - the sketch `Ring` on XY, a circle `c` beside `B`, so no straight line;
/// - the construction plane `Mid`, 20 mm along X from YZ, through the middle of `B`.
fn design(level: ParamCheck) -> (Session, Value, Value, Value) {
let mut s = Session { param_check: level, ..Session::default() };
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
run("solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
run("solid.box", json!({"corner": [-5, 10, 25], "length": 50, "width": 5, "height": 5, "body_name": "Tool"}));
let cuts = run("sketch.create", json!({"plane": "XY", "name": "Cuts"}))["sketch"].clone();
run("sketch.line", json!({"points": [[10, -5], [10, 35]], "ids": ["cut"]}));
run("sketch.line", json!({"points": [[30, -5], [30, 35]]}));
run("sketch.finish", json!({}));
let ring = run("sketch.create", json!({"plane": "XY", "name": "Ring"}))["sketch"].clone();
run("sketch.circle.center", json!({"center": [60, 60], "radius": 2, "id": "c"}));
run("sketch.finish", json!({}));
let mid = run("construct.plane.offset", json!({"base": "YZ", "offset": 20, "name": "Mid"}))["feature"].clone();
assert!(cuts.is_u64() && ring.is_u64() && mid.is_u64(), "{cuts} {ring} {mid}");
(s, cuts, ring, mid)
}
/// Calls that fail today give the same message in strict, each on a fresh [`design`]: what the
/// contracts check (the body, the faces, each tool's form) and what they leave to the handler (a
/// body, sketch, line, plane or face the design lacks, a sketch line that isn't straight, a tool
/// point off the tool, and no tool at all). Split Face without `faces` is left out: today it says
/// "`faces` must be a list of points or face names", and strict "`faces` is required", as for
/// every missing key.
#[test]
fn strict_keeps_todays_errors() {
let (_, cuts, ring, _) = design(ParamCheck::Off);
let cases = [
("solid.split_body", json!({})),
("solid.split_body", json!({"plane": "XY"})),
("solid.split_body", json!({"body": 5, "plane": "XY"})),
("solid.split_body", json!({"body": null, "plane": "XY"})),
("solid.split_body", json!({"body": "Nope", "plane": "XY"})),
("solid.split_body", json!({"body": "B"})),
("solid.split_body", json!({"body": "B", "plane": null, "tool": null, "sketch": null, "line": null})),
("solid.split_body", json!({"body": "B", "plane": true})),
("solid.split_body", json!({"body": "B", "plane": 7.5})),
("solid.split_body", json!({"body": "B", "plane": -1})),
("solid.split_body", json!({"body": "B", "plane": "Nowhere"})),
("solid.split_body", json!({"body": "B", "plane": "Cuts"})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [0, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": {"origin": "here", "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [0, 0, 0], "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "tool": "Tool"})),
("solid.split_body", json!({"body": "B", "tool": {"point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": 5, "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool"}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [20, 10]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Nope", "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [500, 500, 500]}})),
("solid.split_body", json!({"body": "B", "sketch": "Cuts", "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": -1, "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": 999, "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": cuts})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": null})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": 5})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": "nope"})),
("solid.split_body", json!({"body": "B", "sketch": ring, "line": "c"})),
("solid.split_body", json!({"body": "B", "line": "cut"})),
("solid.split_face", json!({"faces": 5, "plane": "XY"})),
("solid.split_face", json!({"faces": [], "plane": "XY"})),
("solid.split_face", json!({"faces": [[10, 15]], "plane": "XY"})),
("solid.split_face", json!({"faces": [[500, 500, 500]], "plane": "Mid"})),
("solid.split_face", json!({"faces": ["NoSuchFace"], "plane": "Mid"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "body": "Nope", "plane": "Mid"})),
("solid.split_face", json!({"faces": [[10, 15, 20]]})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": null, "tool": null, "sketch": null})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": true})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": "Nowhere"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"normal": [0, 0, 0]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": "Tool"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": {"body": "Tool", "point": "here"}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": {"body": "Nope", "point": [20, 10, 27]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "sketch": true})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "sketch": "Nope"})),
];
for (id, p) in cases {
let error = |level: ParamCheck| design(level).0.execute(id, &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{id} {p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
}
}
/// #13127: each contract takes what its input schema allows: each call's check finds no issue
/// exactly when the call matches the schema. A call that gives no tool matches it; the handler
/// refuses it.
#[test]
fn the_check_takes_what_the_schema_allows() {
let body = input_schema_for::<SplitBodyParams>();
let face = input_schema_for::<SplitFaceParams>();
for (id, schema, p, takes) in [
("solid.split_body", &body, json!({"body": "B", "plane": "XY"}), true),
("solid.split_body", &body, json!({"body": "B", "plane": 3}), true),
(
"solid.split_body",
&body,
json!({"body": "B", "plane": {"origin": [0, "w", 0], "normal": [1, 0, 0]}, "name": "S", "body_names": ["L", "R"]}),
true,
),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T", "point": [0, 0, 0]}}), true),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T", "point": {"x": 0, "y": 0, "z": 0}, "side": 1}}), true),
("solid.split_body", &body, json!({"body": "B", "sketch": 4, "line": "cut", "participants": ["*"]}), true),
("solid.split_body", &body, json!({"body": "B", "sketch": null, "line": null, "tool": null, "plane": null}), true),
("solid.split_body", &body, json!({"body": "B", "line": "cut"}), true),
("solid.split_body", &body, json!({"body": ""}), true),
("solid.split_body", &body, json!({}), false),
("solid.split_body", &body, json!({"body": 5, "plane": "XY"}), false),
("solid.split_body", &body, json!({"body": "B", "plane": true}), false),
("solid.split_body", &body, json!({"body": "B", "plane": -1}), false),
("solid.split_body", &body, json!({"body": "B", "plane": {"origin": [0, 0, 0]}}), false),
("solid.split_body", &body, json!({"body": "B", "tool": "T"}), false),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T"}}), false),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": 1, "point": [0, 0, 0]}}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": "Cuts", "line": "cut"}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": -2, "line": "cut"}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": 4, "line": 7}), false),
("solid.split_body", &body, json!({"body": "B", "plane": "XY", "faces": [[0, 0, 0]]}), false),
("solid.split_body", &body, json!({"body": "B", "plane": "XY", "body_name": 3}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY"}), true),
(
"solid.split_face",
&face,
json!({"faces": ["F1", {"point": [1, 2, 3]}], "body": "B", "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}),
true,
),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"body": "T", "point": [1, 2, 3]}}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts"}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": 7, "name": "Split", "participants": ["*"]}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": null, "sketch": null, "plane": null, "body": null}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]]}), true),
("solid.split_face", &face, json!({"plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [], "plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2]], "plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "body": 5, "plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": true}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"point": [1, 2, 3]}}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY", "line": "cut"}), false),
] {
let schema = schema.as_value();
let issues = crate::schema::issues(id, &p);
assert_eq!(issues.is_empty(), takes, "{id} {p}: {issues:?}");
assert_eq!(conforms(schema, schema, &p), takes, "{id} {p}");
}
}
/// #13127: both commands reply with `FeatureResult`, with the keys they gave before, for each
/// tool: in Warn, without warnings, so their contracts take each call. Split Face changes `B` and
/// makes nothing; Split Body cuts `B` in two, so two bodies are made or changed between them.
#[test]
fn split_body_and_split_face_reply_with_feature_result() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
let (_, cuts, _, mid) = design(ParamCheck::Warn);
let calls = [
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "body": "B", "plane": "Mid", "name": "Halves"})),
("solid.split_face", json!({"faces": [[20, 5, 20]], "body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})),
("solid.split_face", json!({"faces": [[20, 15, 20]], "sketch": "Cuts", "tool": null, "plane": null})),
("solid.split_face", json!({"faces": [[20, 15, 20]], "sketch": cuts.clone(), "participants": ["B"]})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [20, 0, "0 mm"], "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": mid, "name": "Halves"})),
("solid.split_body", json!({"body": "B", "plane": "Mid", "tool": null, "sketch": null, "line": null})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": "cut"})),
];
for (id, p) in calls {
let (mut s, ..) = design(ParamCheck::Warn);
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, before, "{id} {p}: {r}");
let count = |key: &str| r[key].as_array().map_or(0, Vec::len);
if id == "solid.split_face" {
assert_eq!((count("created_bodies"), r["changed_bodies"].clone()), (0, json!(["B"])), "{id} {p}: {r}");
} else {
assert!(count("created_bodies") >= 1 && count("created_bodies") + count("changed_bodies") == 2, "{id} {p}: {r}");
}
}
}
+36
View File
@@ -974,6 +974,42 @@ pick_list!(
"The edges, 1 to 1000."
);
/// A face of a body whose surface, extended, cuts like a plane: the `tool` of `solid.split_body`,
/// `solid.split_face` and `surface.trim`, which read `tool.body` and then `tool.point` with these
/// messages. Other keys in it are ignored, as the handlers ignore them. Whether the body exists
/// and the point is on one of its faces is the handler's to check.
#[derive(Clone, Debug, PartialEq)]
pub struct ToolFace {
pub body: String,
pub point: Vec3,
}
impl<'de> Deserialize<'de> for ToolFace {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
// In the handlers' order: the body (anything but an object has none), then the point.
let body = v.get("body").and_then(Value::as_str).ok_or_else(|| D::Error::custom(whole("`tool.body` must name a body")))?;
let point =
v.get("point").and_then(params::vec3).ok_or_else(|| D::Error::custom(whole("`tool.point` must be [x, y, z] on the tool face")))?;
Ok(ToolFace { body: body.to_string(), point })
}
}
impl JsonSchema for ToolFace {
fn schema_name() -> Cow<'static, str> {
"ToolFace".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "A face of a body, its surface extended: the body's name and a point in mm on the face.",
"type": "object",
"properties": {"body": {"type": "string"}, "point": point},
"required": ["body", "point"]
})
}
}
/// A sketch referred to by its feature id (a number) or its name, as `features::sketch_id` reads
/// it, with its message for a top-level key (`path_sketch`, `solid.split_face`'s `sketch`…);
/// for a nested one (a section's `sketch`) the message names the key as `sections[0].sketch`
-4
View File
@@ -7,7 +7,6 @@ doc.recover
doc.recovery_discard
doc.recovery_list
face.delete
file.export
file.insert_mesh
file.insert_step
file.new
@@ -24,7 +23,6 @@ render.preflight
render.scene
render.start
sheet.export_dxf
sketch.draw.dimensioned
solid.align
solid.coil
solid.draft
@@ -41,8 +39,6 @@ solid.press_pull
solid.replace_face
solid.revolve
solid.shell
solid.split_body
solid.split_face
solid.thread
surface.blend
surface.extend
+121
View File
@@ -400,3 +400,124 @@ fn an_insert_after_a_mirror_keeps_its_meshes_through_a_drag() {
}
assert_as_evaluated_afresh(&s);
}
/// The model of `s` against a fresh evaluation of its design: each body's name, volume, bounds
/// and reference mesh. Not the shown meshes: a copy's display triangles can tie differently by
/// the pose they were made at, with or without reuse (as in the drag test above).
fn assert_model_as_evaluated_afresh(s: &Session) {
let mut fresh = solidcraft_doc::Model::new();
fresh.no_cache = true;
fresh.evaluate(&s.doc);
let errors: Vec<_> = fresh.results.iter().filter_map(|r| r.error.as_ref()).collect();
assert!(errors.is_empty(), "{errors:?}");
let (got, want) = (s.model.state(), fresh.state());
assert_eq!(got.bodies.len(), want.bodies.len());
for (a, b) in got.bodies.iter().zip(&want.bodies) {
assert_eq!(a.name, b.name);
let (va, vb) = (a.measure().unwrap().volume, b.measure().unwrap().volume);
assert!((va - vb).abs() < 1e-6, "{}: {va} != {vb}", a.name);
assert_eq!(a.mesh().triangles, b.mesh().triangles, "{}", a.name);
let (ma, mb) = (a.mesh().bounds(), b.mesh().bounds());
assert!(ma.min.dist(mb.min) < 1e-6 && ma.max.dist(mb.max) < 1e-6, "{}: {ma:?} != {mb:?}", a.name);
}
}
/// Driving a joint evaluates a mirror of the part it moves again, and the features after it
/// only when they read its copy: a box, a sketch and its extrusion, and a cut into another
/// body are kept (#12139).
#[test]
fn a_joint_drive_evaluates_only_what_reads_the_mirrored_copy() {
for dependent in [false, true] {
let mut s = Session::default();
moved_arm(&mut s);
run(
&mut s,
"joint.as_built",
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
);
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
run(&mut s, "solid.box", json!({"corner": [0, -100, 0], "length": 10, "width": 10, "height": 10, "body_name": "Later"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-100, -100], "p1": [-90, -90]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 4, "operation": "new"}));
run(&mut s, "solid.box", json!({"corner": [2, -98, 5], "length": 2, "width": 2, "height": 10, "operation": "cut", "targets": ["Later"]}));
if dependent {
run(&mut s, "solid.move", json!({"bodies": [copy.clone()], "translate": [0, 0, 5]}));
}
for angle in [20, -35, 60] {
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": angle, "quiet": true}));
let recomputed = s.model.last_recomputed + s.model.last_restored;
if dependent {
assert_eq!(recomputed, 2, "the mirror and the move of its copy at {angle}°");
} else {
assert_eq!(recomputed, 1, "only the mirror at {angle}°");
}
assert_box(world_box(&s, &copy), {
let (lo, hi) = mirrored_yz(&s, "ArmBody");
if dependent { (lo + Vec3::new(0., 0., 5.), hi + Vec3::new(0., 0., 5.)) } else { (lo, hi) }
});
assert_model_as_evaluated_afresh(&s);
}
for command in ["edit.undo", "edit.redo"] {
run(&mut s, command, json!({}));
assert_model_as_evaluated_afresh(&s);
}
}
}
/// A saved model's pose-only mirror was made at the pose the design had when it was saved: one
/// adopted for a design posed otherwise is evaluated again, not taken from the cache (#12139).
#[test]
fn an_adopted_model_mirrors_at_the_designs_own_pose() {
let mut s = Session::default();
moved_arm(&mut s);
run(
&mut s,
"joint.as_built",
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
);
run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"}));
run(&mut s, "solid.box", json!({"corner": [0, -100, 0], "length": 10, "width": 10, "height": 10, "body_name": "Later"}));
let saved = s.model.saved(&s.doc).unwrap();
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": 40, "quiet": true}));
// As opening installs it, before any evaluation; and once evaluated.
s.model = solidcraft_doc::Model::adopt(&s.doc, &saved).unwrap();
assert_model_as_evaluated_afresh(&s);
let before = s.model.recomputed;
s.model.evaluate(&s.doc);
assert_eq!(s.model.recomputed, before, "the adopted model is current");
assert_model_as_evaluated_afresh(&s);
}
/// A cut revolved about an edge of a mirrored copy turns about it where the copy now is, when
/// a joint drive moves the copy: the axis is a pick of the model, not a body by name (#12139).
/// (Not checked against a fresh evaluation: one without the cache finds no edge at the pick.)
#[test]
fn a_revolve_about_a_mirrored_copys_edge_follows_a_joint_drive() {
let mut s = Session::default();
moved_arm(&mut s);
run(
&mut s,
"joint.as_built",
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
);
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
// An edge of the copy along X, on its lowest Y and Z.
let (lo, hi) = world_box(&s, &copy);
let edge = [(lo.x + hi.x) / 2., lo.y, lo.z];
let (x, y) = (edge[0], lo.y - 30.);
run(&mut s, "solid.box", json!({"corner": [x - 50., y - 30., -40], "length": 100, "width": 60, "height": 80, "body_name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x - 5., y], "p1": [x + 5., y + 10.]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"axis": {"edge": edge}, "angle": "360 deg", "operation": "cut", "targets": ["Block"]}));
let block = |s: &Session| s.model.state().body("Block").unwrap().mesh().positions.clone();
let before = block(&s);
for angle in [5, -5] {
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": angle, "quiet": true}));
assert_eq!(s.model.last_recomputed + s.model.last_restored, 2, "the mirror and the revolve at {angle}°");
let after = block(&s);
assert_ne!(after, before, "the cut turned about the old axis at {angle}°");
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ impl State {
State::Failed => "failed",
}
}
fn label(self) -> &'static str {
pub(crate) fn label(self) -> &'static str {
tr(match self {
State::Off => "ui.agent_chat_state_off",
State::Starting => "ui.agent_chat_state_starting",
+115 -24
View File
@@ -6,6 +6,7 @@ use crate::fonts::RichTextExt as _;
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{Container, Edge, Surface};
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
use solidcraft_engine::panels::Flow;
use super::*;
use crate::ui_kit;
@@ -240,7 +241,7 @@ pub(crate) fn finish_shared(app: &mut SolveApp, ctx: &egui::Context) {
}
/// Existing Assistant controls and conversation, inside the shared host's body.
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui) {
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
let rect = ui.max_rect().intersect(ui.clip_rect());
if !shown(app) || !app.agent_chat.open || !rect.is_finite() || !rect.is_positive() {
return;
@@ -248,7 +249,7 @@ pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui) {
app.agent_chat.panel_rect = Some(rect);
app.agent_chat.drop_rect = Some(rect);
crate::scenario::publish_rect("agent_chat:panel", rect);
ui_kit::scope(ui, |ui| content(app, ui));
ui_kit::scope(ui, |ui| content(app, ui, flow));
}
/// Horizontal room reserved before the history and browser when Assistant is on the left.
@@ -904,10 +905,12 @@ fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: boo
fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
header(app, ui, left);
content(app, ui);
content(app, ui, Flow::Tall);
}
fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
fn content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
#[cfg(target_arch = "wasm32")]
let _ = flow;
#[cfg(not(target_arch = "wasm32"))]
if !crate::panel_runtime::shared_content_measurement(ui.ctx()) {
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
@@ -917,17 +920,61 @@ fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.label(tr("ui.agent_chat_desktop_only"));
}
#[cfg(not(target_arch = "wasm32"))]
desktop_body(app, ui);
desktop_body(app, ui, flow);
}
#[cfg(not(target_arch = "wasm32"))]
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
let top = ui.cursor().top();
let shared = crate::panel_runtime::shared_content_measurement(ui.ctx());
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, ui.ctx());
}
let state = app.agent_chat.state();
let bounds = ui.available_rect_before_wrap();
let gap = Tokens::get().space_8;
// Compact wide cards keep the existing stack until both regions fit.
let wide = shared && flow == Flow::Wide && bounds.width() >= MIN_WIDTH * 2.0 + gap;
let (interaction_bounds, transcript_bounds) = if wide {
let width = (bounds.width() - gap) / 2.0;
let left = Rect::from_min_size(bounds.min, vec2(width, bounds.height()));
let right = Rect::from_min_max(pos2(bounds.right() - width, bounds.top()), bounds.max);
if crate::i18n::panel_direction(&app.preferences.language) == solidcraft_engine::panels::Direction::Rtl {
(left, Some(right))
} else {
(right, Some(left))
}
} else {
(bounds, None)
};
let root = ui.scope_id();
// Orientation changes rectangles, never the retained region identities.
let mut interaction = shared.then(|| {
let mut child = ui.new_child(egui::UiBuilder::new().scope_id(root.with("assistant-interaction")).max_rect(interaction_bounds));
child.set_clip_rect(ui.clip_rect().intersect(interaction_bounds));
child
});
let (top_height, outcome, remaining) = {
let column = interaction.as_mut().unwrap_or(&mut *ui);
let top_height = top_controls(app, column, state, shared);
let outcome = composer(app, column, state);
(top_height, outcome, column.available_rect_before_wrap())
};
let mut transcript = shared.then(|| {
let bounds = transcript_bounds.unwrap_or(remaining);
let mut child = ui.new_child(egui::UiBuilder::new().scope_id(root.with("assistant-transcript")).max_rect(bounds));
child.set_clip_rect(ui.clip_rect().intersect(bounds));
child
});
let transcript_height = transcript_body(app, transcript.as_mut().unwrap_or(&mut *ui));
let interaction_height = top_height + outcome.height;
let height = if wide { interaction_height.max(transcript_height) } else { interaction_height + transcript_height };
crate::panel_runtime::report_content_extent(ui, top, height, false);
finish_composer(app, ui.ctx(), outcome);
}
#[cfg(not(target_arch = "wasm32"))]
fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.agent_chat.confirm_new {
ui_kit::scope(ui, |ui| {
inset(ui, |ui| {
@@ -935,7 +982,7 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::text(ui, tr("ui.agent_chat_new_confirm"), ui_kit::Text::Report("new_chat", false));
ui.horizontal(|ui| {
let yes = ui_kit::button(ui, tr("ui.agent_chat_new"), ui_kit::Button::Normal);
crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center());
crate::scenario::publish_target("agent_chat:new_confirm", yes.rect);
if yes.clicked() {
new_chat(app);
}
@@ -946,6 +993,23 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
});
});
}
}
#[cfg(not(target_arch = "wasm32"))]
fn top_controls(app: &mut SolveApp, ui: &mut egui::Ui, state: State, shared: bool) -> f32 {
let top = ui.cursor().top();
let reveal_confirmation = shared && {
let id = ui.scope_id().with("assistant-confirm-visible");
let visible = app.agent_chat.confirm_new;
ui.data_mut(|data| {
let previous = data.get_temp::<bool>(id).unwrap_or(false);
data.insert_temp(id, visible);
visible && !previous
})
};
if !shared {
new_chat_confirmation(app, ui);
}
// Shared top controls must yield scroll space to the pinned composer, including
// when discovery lists several agents or a long message grows the editor.
let picker_max = if shared {
@@ -956,24 +1020,41 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
} else {
ui.available_height() / 3.0
};
let picker = egui::ScrollArea::vertical()
let provider_scroll = egui::ScrollArea::vertical()
.id_salt("sc_agent_chat_picker_scroll")
.min_scrolled_height(0.0)
.max_height(picker_max)
.auto_shrink([false, true])
.show(ui, |ui| {
if shared {
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
}
picker(app, ui);
if shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(app, ui));
}
});
.auto_shrink([false, true]);
let provider_scroll = if reveal_confirmation { provider_scroll.vertical_scroll_offset(0.0) } else { provider_scroll };
let picker = provider_scroll.show(ui, |ui| {
crate::scenario::publish_rect("agent_chat:providers", ui.clip_rect());
if shared {
new_chat_confirmation(app, ui);
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
}
picker(app, ui);
if shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(app, ui));
}
});
if !shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(app, ui));
}
let top_height = ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y;
ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y
}
#[cfg(not(target_arch = "wasm32"))]
struct ComposerOutcome {
height: f32,
send_clicked: bool,
stop_clicked: bool,
enter: bool,
steer: bool,
esc: bool,
}
#[cfg(not(target_arch = "wasm32"))]
fn composer(app: &mut SolveApp, ui: &mut egui::Ui, state: State) -> ComposerOutcome {
// The input sits at the bottom of the panel and grows upwards: a long message makes its box
// taller up to `edit_max`, then it scrolls, and the conversation takes what is left above. It
// is a panel of its own at the bottom, as high as its content this frame, so the buttons under
@@ -1148,6 +1229,11 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
if pick_clicked {
start_pick(app);
}
ComposerOutcome { height: composer.inner, send_clicked, stop_clicked, enter, steer, esc }
}
#[cfg(not(target_arch = "wasm32"))]
fn transcript_body(app: &mut SolveApp, ui: &mut egui::Ui) -> f32 {
let conversation = egui::ScrollArea::vertical()
.id_salt("sc_agent_chat_scroll")
.min_scrolled_height(0.0)
@@ -1163,7 +1249,12 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
scroll_while_selecting(ui);
});
crate::scenario::publish_rect("agent_chat:conversation", conversation.inner_rect);
crate::panel_runtime::report_content_extent(ui, top, top_height + composer.inner + conversation.content_size.y, false);
conversation.content_size.y
}
#[cfg(not(target_arch = "wasm32"))]
fn finish_composer(app: &mut SolveApp, ctx: &egui::Context, outcome: ComposerOutcome) {
let ComposerOutcome { send_clicked, stop_clicked, enter, steer, esc, .. } = outcome;
if esc || stop_clicked {
cancel(app);
}
@@ -1174,13 +1265,13 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
// Enter waits with the button.
} else if (send_clicked || enter || steer) && something {
let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string();
send(app, ui.ctx(), text);
send(app, ctx, text);
if steer {
send_now(app, ui.ctx());
send_now(app, ctx);
}
} else if steer {
// Nothing typed: Ctrl+Enter sends what is already queued.
send_now(app, ui.ctx());
send_now(app, ctx);
}
}
@@ -2550,7 +2641,7 @@ mod tests {
ui.set_width(width);
ui_kit::scope(ui, |ui| {
header(&mut app, ui, false);
desktop_body(&mut app, ui);
desktop_body(&mut app, ui, Flow::Tall);
});
});
output.textures_delta.clear();
+246
View File
@@ -4762,3 +4762,249 @@ fn a_picture_only_prompt_and_a_thought_have_no_copy_message_action() {
assert!(crate::scenario::handle_at("agent_chat:copy:1").is_none(), "a thought has no copy action");
assert!(h.widget("Copy message").is_none(), "nothing to click");
}
fn shared_assistant_harness() -> (crate::scenario::Harness, Script) {
let mut h = narrow_chat();
h.app.home.open = false;
h.app.ui.show_browser = false;
h.app.ui.show_timeline = false;
h.app.preferences.ai_features = true;
h.app.preferences.agent_chat_popped = false;
h.app.preferences.language = "en".into();
let script = Script::default();
h.app.agent_chat.backend = Some(Box::new(script.clone()));
h.app.agent_chat.state = Some(State::Ready);
h.app.agent_chat.input = "Retained responsive composer draft".into();
h.app.agent_chat.items.push(Item::Agent { thought: false, text: "A retained conversation".into() });
h.app.run("sketch.create", json!({"plane":"XY","name":"Retained profile"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let feature = h.app.session.doc.features[0].id;
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Feature { id: feature }]})).unwrap();
h.app.preferences.shared_panel_layout = true;
h.frames(6);
assert!(h.app.panel_runtime().is_some());
assert!(h.app.agent_chat.detecting.is_none(), "the deterministic discovery fixture starts no worker");
assert!(script.calls().is_empty());
(h, script)
}
fn shared_assistant_float(h: &mut crate::scenario::Harness, width: f32, height: f32) {
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, Point, SectionId, SectionLocation, Size};
let instance = InstanceId::new("assistant").unwrap();
let source = h.app.panel_runtime().unwrap().layout().layout().containing(&instance).unwrap();
let origin = Point { x: 300.0, y: 200.0 };
let size = Size { width, height };
let mutation = if matches!(source.location, SectionLocation::Floating { .. }) {
Mutation::SetFloatingRect { section: source.id.clone(), origin, size }
} else {
Mutation::Move {
instance,
destination: Destination::NewSection {
section: SectionId::new("responsive-assistant").unwrap(),
index: 0,
location: SectionLocation::Floating { origin },
size,
},
}
};
h.app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: None, mutations: vec![mutation] }).unwrap();
h.frames(6);
}
fn shared_assistant_body(h: &crate::scenario::Harness) -> egui::Rect {
let body = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|panel| panel.instance.0 == "assistant").unwrap().body.unwrap();
egui::Rect::from_min_size(egui::pos2(body.origin.x, body.origin.y), egui::vec2(body.size.width, body.size.height))
}
#[test]
fn shared_assistant_wide_and_compact_present_real_regions_without_losing_the_composer_selection() {
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
let (mut h, script) = shared_assistant_harness();
let input = egui::Id::unique("sc_agent_chat_input");
h.ctx.memory_mut(|memory| memory.request_focus(input));
h.frames(2);
let mut editor = egui::TextEdit::load_state(&h.ctx, input).unwrap();
editor.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(2), egui::text::CCursor::new(10))));
editor.store(&h.ctx, input);
h.frame();
let caret = egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range();
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input), "the docked composer starts focused");
let response = h.ctx.read_response(input).unwrap();
assert!(response.enabled() && response.interact_rect.is_positive(), "the focused composer is live and interactable");
assert!(!caret.unwrap().is_empty(), "exercise a real selection before moving the panel");
let draft = h.app.agent_chat.input.clone();
let document = h.app.session.doc.clone();
let model = h.app.session.model.state();
let selection = h.app.session.selection.clone();
let camera = h.app.cam;
let session = h.app.session.uid;
let revision = h.app.session.revision;
let undo = h.app.session.undo.len();
let dirty = h.app.session.is_dirty();
let controllers = h.app.panel_runtime().unwrap().controller_count();
for (width, height, wide) in [(900.0, 420.0, true), (500.0, 340.0, false)] {
shared_assistant_float(&mut h, width, height);
let body = shared_assistant_body(&h);
assert!(body.width() > body.height(), "both cases exercise the Wide host flow");
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
let switcher = crate::scenario::rect_of("agent_chat:switcher").unwrap();
assert!(body.expand(1.0).contains_rect(conversation));
assert!(body.expand(1.0).contains_rect(composer));
assert!(body.expand(1.0).contains_rect(switcher));
if wide {
assert!(conversation.right() <= composer.left(), "a viable Wide body puts the real transcript beside its composer");
assert!(conversation.right() <= switcher.left(), "provider controls share the composer's column");
assert!(conversation.height() > composer.height(), "the transcript can use the full height of its own column");
} else {
assert!(conversation.bottom() <= composer.top() + 1.0, "a compact Wide body falls back to the existing vertical composition");
assert!(conversation.left() < composer.right() && composer.left() < conversation.right());
}
assert_eq!(h.app.agent_chat.input, draft);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input));
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
}
h.app.preferences.language = "ar".into();
shared_assistant_float(&mut h, 900.0, 420.0);
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
assert!(composer.right() <= conversation.left(), "Wide mirrors the two real regions in RTL");
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input), "RTL keeps the composer focused");
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
h.app.preferences.language = "en".into();
h.app
.dispatch_panel_layout(LayoutAction::Apply {
expected_revision: None,
mutations: vec![Mutation::Move {
instance: InstanceId::new("assistant").unwrap(),
destination: Destination::NewSection {
section: SectionId::new("responsive-assistant-side").unwrap(),
index: 0,
location: SectionLocation::Dock { zone: Zone::Right },
size: Size { width: 360.0, height: 420.0 },
},
}],
})
.unwrap();
h.frames(6);
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
assert!(conversation.bottom() <= composer.top() + 1.0);
assert_eq!(h.app.agent_chat.input, draft);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input));
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
assert_eq!(h.app.session.uid, session);
assert!(Arc::ptr_eq(&h.app.session.doc, &document));
assert!(Arc::ptr_eq(&h.app.session.model.state(), &model));
assert_eq!(h.app.session.selection, selection);
assert_eq!(h.app.cam, camera);
assert_eq!(h.app.session.revision, revision);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.is_dirty(), dirty);
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
assert!(script.calls().is_empty(), "changing presentation must not submit, cancel or restart the retained service");
}
#[test]
fn shared_assistant_transcript_keeps_its_scroll_position_through_responsive_width_changes() {
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
let (mut h, script) = shared_assistant_harness();
h.app.agent_chat.items = (0..80).map(|n| Item::Agent { thought: false, text: format!("Parts list row {n}") }).collect();
shared_assistant_float(&mut h, 900.0, 420.0);
assert_eq!(rows_in_view(&h).last().unwrap().0, 79);
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
h.call("ui.scroll", json!({"x":conversation.center().x,"y":conversation.center().y,"delta":1200}));
h.frames(30);
let before = rows_in_view(&h).first().unwrap().0;
assert!(before > 0 && before < 70, "exercise a middle position, not an automatic endpoint");
for width in [1040.0, 900.0] {
shared_assistant_float(&mut h, width, 420.0);
let after = rows_in_view(&h).first().unwrap().0;
assert!(before.abs_diff(after) <= 1, "fixed-height short messages keep their scroll anchor across a width change: {before} → {after}");
assert!(!rows_in_view(&h).iter().any(|row| row.0 == 79), "a reparent must not reset stick-to-bottom");
}
h.app
.dispatch_panel_layout(LayoutAction::Apply {
expected_revision: None,
mutations: vec![Mutation::Move {
instance: InstanceId::new("assistant").unwrap(),
destination: Destination::NewSection {
section: SectionId::new("scrolled-assistant-side").unwrap(),
index: 0,
location: SectionLocation::Dock { zone: Zone::Right },
size: Size { width: 360.0, height: 420.0 },
},
}],
})
.unwrap();
h.frames(6);
let after = rows_in_view(&h).first().unwrap().0;
assert!(before.abs_diff(after) <= 1, "returning to Tall retains the same transcript scroll anchor: {before} → {after}");
assert!(script.calls().is_empty());
assert!(h.app.agent_chat.backend.is_some());
}
#[test]
fn shared_assistant_header_uses_live_status_and_one_click_starts_one_new_chat() {
use crate::panel_runtime::{HostControlTarget, HostPointerTarget};
use solidcraft_engine::panels::{ControlId, InstanceId};
let (mut h, script) = shared_assistant_harness();
shared_assistant_float(&mut h, 900.0, 420.0);
let assistant = InstanceId::new("assistant").unwrap();
for state in [State::Ready, State::Busy, State::NeedsAuth, State::Exited, State::Failed] {
h.app.agent_chat.state = Some(state);
h.frames(2);
let presentation = h.app.panel_runtime().unwrap().instance_presentation(&assistant).unwrap();
assert_eq!(presentation.status.as_deref(), Some(state.label()));
assert_eq!(presentation.attention, matches!(state, State::NeedsAuth | State::Exited | State::Failed));
}
h.app.agent_chat.state = Some(State::Ready);
h.app.agent_chat.items.push(Item::Permission { key: 42, title: "Review this local request".into(), options: Vec::new(), answer: None });
h.frames(2);
let presentation = h.app.panel_runtime().unwrap().instance_presentation(&assistant).unwrap();
assert_eq!(presentation.status.as_deref(), Some(trf("ui.assistant_waiting", &[("count", "1")]).as_str()));
assert!(presentation.attention);
assert_eq!(h.app.agent_chat.waiting(), 1, "rendering status never answers the pending request");
h.app.agent_chat.items.pop();
h.app.agent_chat.pending.push(Pending {
text: "Keep this queued request until confirmation".into(),
image: false,
context: Context::default(),
files: Vec::new(),
});
h.app.preferences.agents =
(0..24).map(|n| agents::AgentConfig { id: format!("review-{n}"), name: format!("Review provider {n}"), ..Default::default() }).collect();
h.app.agent_chat.picking = Some(true);
h.frames(4);
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
assert!(providers.contains(crate::scenario::handle_at("agent_chat:pick:review-0").unwrap()));
h.call("ui.scroll", json!({"x":providers.center().x,"y":providers.center().y,"delta":-900}));
h.frames(30);
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
let switcher = crate::scenario::rect_of("agent_chat:switcher").unwrap();
assert!(
switcher.bottom() < providers.top() - solidcraft_theme::Tokens::get().control_height * 4.0,
"the real provider scroller is far from its top before New Chat"
);
assert!(script.calls().is_empty(), "scrolling the deterministic provider list starts no agent");
let target = HostControlTarget::Pointer(HostPointerTarget::HeaderAction { instance: assistant, action: ControlId::new("new-chat").unwrap() });
let control = h.app.universal_panel_frame().unwrap().controls.iter().find(|control| control.target == target).unwrap();
let at = egui::pos2(control.bounds.origin.x + control.bounds.size.width / 2.0, control.bounds.origin.y + control.bounds.size.height / 2.0);
click_at(&mut h, at, egui::PointerButton::Primary);
h.frames(4);
assert!(h.app.agent_chat.confirm_new);
assert_eq!(h.app.agent_chat.pending.len(), 1);
assert!(script.calls().is_empty(), "opening confirmation cannot invoke New Chat early");
let confirm = crate::scenario::rect_of("agent_chat:new_confirm").unwrap();
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
assert!(providers.expand(1.0).contains_rect(confirm), "a newly requested confirmation is fully visible inside the actual provider clip");
assert!(shared_assistant_body(&h).contains_rect(confirm));
assert!(confirm.left() >= composer.left(), "the real confirmation remains reachable in the interaction column");
click_at(&mut h, confirm.center(), egui::PointerButton::Primary);
h.frames(4);
assert_eq!(script.calls().iter().filter(|call| call.as_str() == "new_session").count(), 1);
assert!(!script.calls().iter().any(|call| call.starts_with("prompt:") || call == "cancel"));
assert_eq!(h.app.agent_chat.input, "Retained responsive composer draft");
assert!(h.app.agent_chat.items.is_empty());
}
+1 -1
View File
@@ -97,7 +97,7 @@ fn every_axis_is_drawn_in_one_set_of_colours() {
for (name, d, want) in [("x", Vec3::X, t.axis_x), ("y", Vec3::Y, t.axis_y), ("z", Vec3::Z, t.axis_z)] {
let at = c + vec2(32.0, 0.0);
let mut out =
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, &d, at, 0.5, false, t));
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, d.into(), at, 0.5, false, t));
out.textures_delta.clear();
let end = out.shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Circle(e) if e.center == at => Some(e.fill),
+2
View File
@@ -192,6 +192,7 @@ mod file_load_ui_tests;
#[cfg(test)]
mod fillet_spline_handle_tests;
pub mod mouse_gesture;
mod nav_bar;
pub(crate) mod navigation;
pub mod navigation_bar;
mod navigation_bar_view;
@@ -318,6 +319,7 @@ mod ui_kit_guard_tests;
pub mod update;
mod value_step;
pub(crate) mod view_angles;
pub mod view_compass;
mod view_confirm;
pub mod view_gizmo;
pub mod view_pick;
+386
View File
@@ -0,0 +1,386 @@
//! The navigation bar as a component. The app measures the row, describes each slot and each
//! menu as plain data ([`Bar`], [`SlotView`], [`Menu`]) and runs the [`NavAction`] the bar
//! returns; the bar keeps which menu is open and reads nothing of the app. Every slot, menu row
//! and context item publishes its rect to the widget registry while it is drawn.
use crate::ui_kit::{self, BackingTooltipExt as _, Container, NavigationEdge, NavigationIdle, NavigationKind, NavigationSize, Surface};
use egui::{Align2, Rect, pos2, vec2};
/// One slot as the bar draws it.
#[derive(Clone, Debug)]
pub struct SlotView<'a> {
/// Where the slot is in the stored list (what Remove removes).
pub index: usize,
pub id: &'a str,
pub kind: NavigationKind,
pub icon: &'a str,
/// The text on the button (or beside it, for a menu showing its value).
pub text: String,
/// The slot's name for assistive technology.
pub name: String,
/// Hover text, and the shortcut shown with it.
pub tip: String,
pub key: Option<String>,
/// Its tool or toggle is on.
pub selected: bool,
/// Inert for now (drawn faded, still clickable).
pub enabled: bool,
/// Editing only: the slot is not shown on this canvas.
pub hidden: bool,
/// The button shows its text.
pub show_text: bool,
/// Where it is drawn (unused for a slot in the More menu).
pub rect: Rect,
/// A second registry name its handle is published under, wherever it is drawn.
pub alias: Option<&'a str>,
}
/// Labels of the bar's own controls, in the app's language.
#[derive(Clone, Debug, Default)]
pub struct BarLabels {
pub more: String,
pub customise: String,
pub hide: String,
pub reset: String,
pub remove: String,
}
/// A measured bar and its slots.
#[derive(Clone, Debug)]
pub struct Bar<'a> {
pub rect: Rect,
pub size: NavigationSize,
pub edge: NavigationEdge,
pub idle: NavigationIdle,
/// The app's reasons to show the bar at full strength (the view is being turned, the pointer
/// is over the view compass); the pointer over the bar and an open menu count on their own.
pub active: bool,
pub slots: Vec<SlotView<'a>>,
/// The slots folded into More, in order.
pub overflow: Vec<SlotView<'a>>,
/// The More button (`Rect::NOTHING`: nothing folded).
pub more: Rect,
pub labels: BarLabels,
}
/// A menu a slot opens.
#[derive(Clone, Debug, Default)]
pub struct Menu {
pub entries: Vec<MenuEntry>,
/// A pick closes the menu (otherwise it stays open for the next).
pub close_on_pick: bool,
}
#[derive(Clone, Debug)]
pub enum MenuEntry {
Heading(String),
Separator,
Row(MenuRow),
}
/// One row of a slot's menu.
#[derive(Clone, Debug, Default)]
pub struct MenuRow {
pub id: String,
pub label: String,
pub icon: String,
pub selected: bool,
pub enabled: bool,
/// Text at the row's end (a current value).
pub detail: Option<String>,
pub tip: Option<String>,
/// The registry name its rect is published under.
pub target: String,
/// Rows of the menu this row opens (empty: the row is picked itself).
pub submenu: Vec<MenuRow>,
}
impl MenuRow {
pub fn new(id: impl Into<String>, label: impl Into<String>, icon: impl Into<String>, target: impl Into<String>) -> MenuRow {
MenuRow { id: id.into(), label: label.into(), icon: icon.into(), enabled: true, target: target.into(), ..MenuRow::default() }
}
}
/// An item of the bar's context menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContextItem {
Customise,
Hide,
Reset,
/// Remove the slot at this index of the stored list.
Remove(usize),
}
/// What the user chose on the bar this frame.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NavAction {
/// A slot that is not a menu was clicked (on the bar or in More).
Slot(String),
/// Row `item` of the menu of slot `menu` (or of its submenu) was picked.
Item {
menu: String,
item: String,
},
Context(ContextItem),
}
/// Paint the bar's surface and its slots; the slots' responses in order. The live bar and the
/// Preferences editor (`editing`: the selected slot's index, if any) share it.
pub fn paint_slots(
ui: &mut egui::Ui,
rect: Rect,
size: NavigationSize,
edge: NavigationEdge,
slots: &[SlotView<'_>],
editing: Option<Option<usize>>,
) -> Vec<egui::Response> {
ui_kit::navigation_surface(ui, rect, size, editing.is_some());
let mut responses = Vec::new();
for slot in slots {
let response = ui
.push_id(slot.index, |ui| {
ui.scope(|ui| {
if slot.hidden {
ui.set_opacity(ui_kit::NAV_HIDDEN_OPACITY);
} else if !slot.enabled {
ui.set_opacity(ui.visuals().disabled_alpha);
}
if slot.kind == NavigationKind::Separator {
ui_kit::navigation_separator(ui, slot.rect);
ui.interact(
slot.rect,
ui.scope_id().with("separator"),
if editing.is_some() { egui::Sense::click_and_drag() } else { egui::Sense::click() },
)
} else {
let button = ui_kit::NavigationButton {
id: slot.id,
kind: slot.kind,
icon: slot.icon,
label: &slot.text,
selected: slot.selected,
labels: slot.show_text,
menu: slot.kind == NavigationKind::Menu,
};
if editing.is_some() {
ui_kit::navigation_button_editable(ui, slot.rect, button, true)
} else {
ui_kit::navigation_button(ui, slot.rect, button)
}
}
})
.inner
})
.inner;
let chosen = editing.map_or(slot.selected, |selected| selected == Some(slot.index));
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), chosen, &slot.name));
if editing.is_some() {
ui_kit::navigation_edit_outline(ui, slot.rect, slot.hidden, chosen);
}
ui_kit::navigation_tooltip(&response, rect, edge, &slot.tip, slot.key.as_deref());
responses.push(response);
}
responses
}
/// Draw the live bar, its More button, its context menu and the open menu (`menu` gives a menu
/// slot's rows by its id); what the user chose, for the app to run.
pub fn show(ui: &mut egui::Ui, bar: &Bar<'_>, menu: impl FnOnce(&str) -> Menu) -> Option<NavAction> {
if !bar.rect.is_positive() {
return None;
}
solidcraft_ui_registry::publish_rect("nav:outer", bar.rect);
let menu_id = ui.scope_id().with("navigation_bar_menu");
let mut open = ui.data(|d| d.get_temp::<String>(menu_id)).unwrap_or_default();
let mut action = None;
let active = bar.active || ui.rect_contains_pointer(bar.rect) || !open.is_empty();
let opacity = ui_kit::navigation_opacity(bar.idle, active);
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
let opacity = if bar.idle == NavigationIdle::Hide {
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
} else {
opacity
};
let mut anchor = bar.more;
ui.scope(|ui| {
ui.set_opacity(opacity);
let shown: Vec<_> = bar.slots.iter().map(|s| SlotView { selected: s.selected || open == s.id, ..s.clone() }).collect();
for (slot, response) in shown.iter().zip(paint_slots(ui, bar.rect, bar.size, bar.edge, &shown, None)) {
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
if let Some(alias) = slot.alias {
solidcraft_ui_registry::publish_handle(alias, response.rect.center());
}
context_menu(&response, Some(slot.index), &bar.labels, &mut action);
if response.clicked() && slot.kind != NavigationKind::Separator {
if slot.kind == NavigationKind::Menu {
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
} else {
action = Some(NavAction::Slot(slot.id.to_owned()));
}
}
if open == slot.id {
anchor = response.rect;
}
}
if bar.more.is_positive() {
let response = ui_kit::navigation_button(
ui,
bar.more,
ui_kit::NavigationButton {
id: "more",
kind: NavigationKind::Menu,
icon: "dots",
label: &bar.labels.more,
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
labels: false,
menu: false,
},
);
ui_kit::navigation_tooltip(&response, bar.rect, bar.edge, &bar.labels.more, None);
solidcraft_ui_registry::publish_target("nav:more", response.rect);
context_menu(&response, None, &bar.labels, &mut action);
if response.clicked() {
open = if open == "more" { String::new() } else { "more".to_owned() };
}
}
});
context_menu(&background, None, &bar.labels, &mut action);
if !open.is_empty() && anchor.is_positive() {
let child_popup_open = egui::Popup::is_any_open(ui.ctx());
let ctx = ui.ctx().clone();
// Menus open into the view: up from a bar at the bottom, down from one at the top.
let (pivot, at) = match bar.edge {
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
};
let contents = if open == "more" { None } else { Some(menu(&open)) };
let popup = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
&ctx,
|ui| {
ui_kit::navigation_menu(ui, if open == "more" { ui_kit::NAV_MORE_WIDTH } else { ui_kit::NAV_POPUP_WIDTH }, |ui| match &contents {
None => overflow_menu(ui, bar, &mut open, &mut action),
Some(m) if m.entries.is_empty() => open.clear(),
Some(m) => {
if let Some(item) = menu_rows(ui, &m.entries) {
action = Some(NavAction::Item { menu: open.clone(), item });
if m.close_on_pick {
open.clear();
}
}
}
});
},
);
let outside = !child_popup_open
&& ui.input(|i| {
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !popup.response.rect.contains(p) && !bar.rect.contains(p))
});
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
open.clear();
}
}
ui.data_mut(|d| d.insert_temp(menu_id, open));
action
}
/// The context menu of the bar (`index`: `None`) or of a slot: Customise, Hide and Reset on the
/// bar, Remove on a slot.
fn context_menu(response: &egui::Response, index: Option<usize>, labels: &BarLabels, action: &mut Option<NavAction>) {
ui_kit::navigation_context(response, |ui| {
let items: Vec<(&str, &str, ContextItem)> = match index {
None => vec![
("customise", &labels.customise, ContextItem::Customise),
("hide", &labels.hide, ContextItem::Hide),
("reset", &labels.reset, ContextItem::Reset),
],
Some(index) => vec![("remove", &labels.remove, ContextItem::Remove(index))],
};
for (id, label, item) in items {
let row = ui_kit::NavigationMenuRow::new(id, label, "").show(ui);
solidcraft_ui_registry::publish_target(&format!("nav:context:{id}"), row.rect);
if row.clicked() {
*action = Some(NavAction::Context(item));
ui.close();
}
}
});
}
/// More: the folded slots as rows. A menu slot opens its menu in place of More.
fn overflow_menu(ui: &mut egui::Ui, bar: &Bar<'_>, open: &mut String, action: &mut Option<NavAction>) {
for slot in &bar.overflow {
if slot.kind == NavigationKind::Separator {
let start = ui.next_widget_position();
ui_kit::navigation_menu_separator(ui);
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), bounds);
continue;
}
let mut item = ui_kit::NavigationMenuRow::new(slot.id, &slot.name, slot.icon);
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
item.selected = slot.selected;
}
let response = item.show(ui);
context_menu(&response, Some(slot.index), &bar.labels, action);
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
if let Some(alias) = slot.alias {
solidcraft_ui_registry::publish_handle(alias, response.rect.center());
}
ui_kit::navigation_tooltip(&response, response.rect, NavigationEdge::Bottom, &slot.tip, slot.key.as_deref());
if response.clicked() {
if slot.kind == NavigationKind::Menu {
*open = slot.id.to_owned();
} else {
*action = Some(NavAction::Slot(slot.id.to_owned()));
open.clear();
}
}
}
}
/// A menu's rows; the id of the row picked, if any.
fn menu_rows(ui: &mut egui::Ui, entries: &[MenuEntry]) -> Option<String> {
let mut picked = None;
for entry in entries {
match entry {
MenuEntry::Heading(title) => ui_kit::navigation_menu_heading(ui, title),
MenuEntry::Separator => ui_kit::navigation_menu_separator(ui),
MenuEntry::Row(row) => {
let response = menu_row(ui, row);
if row.submenu.is_empty() {
if response.clicked() {
picked = Some(row.id.clone());
}
continue;
}
Container::new(ui.ctx(), Surface::Popup).menu(&response).frame(ui_kit::navigation_popup_frame()).show(|ui| {
ui.set_width(ui_kit::NAV_POPUP_WIDTH - 2.0 * (f32::from(ui_kit::NAV_POPUP_PADDING) + ui_kit::NAV_BORDER));
ui.spacing_mut().item_spacing.y = 0.0;
for sub in &row.submenu {
if menu_row(ui, sub).clicked() {
picked = Some(sub.id.clone());
ui.close();
}
}
});
}
}
}
picked
}
fn menu_row(ui: &mut egui::Ui, row: &MenuRow) -> egui::Response {
let mut item = ui_kit::NavigationMenuRow::new(&row.id, &row.label, &row.icon);
item.selected = row.selected;
item.enabled = row.enabled;
item.detail = row.detail.as_deref();
item.submenu = !row.submenu.is_empty();
let response = item.show(ui);
let response = match &row.tip {
Some(tip) => response.backing_on_hover_text(tip),
None => response,
};
if !row.target.is_empty() {
solidcraft_ui_registry::publish_target(&row.target, response.rect);
}
response
}
+183
View File
@@ -622,3 +622,186 @@ fn navigation_board_drops_menu_value_text_before_folding_slots() {
assert!(!folded.values && !folded.overflow.is_empty());
assert_eq!(folded.more.size(), vec2(32.0, 32.0));
}
/// The bar component driven with plain data, no app: what each click returns.
mod component {
use super::*;
use crate::nav_bar::{Bar, ContextItem, Menu, MenuEntry, MenuRow, NavAction, SlotView, show};
use egui::{Event, PointerButton, RawInput};
fn slot(index: usize, id: &'static str, kind: NavigationKind, x: f32) -> SlotView<'static> {
SlotView {
index,
id,
kind,
icon: id,
text: id.to_owned(),
name: id.to_owned(),
tip: id.to_owned(),
key: None,
selected: false,
enabled: true,
hidden: false,
show_text: false,
rect: Rect::from_min_size(pos2(x, 304.0), vec2(26.0, 26.0)),
alias: None,
}
}
/// Fit and Styles on the bar; Zoom and View folded into More.
fn bar() -> Bar<'static> {
Bar {
rect: Rect::from_min_size(pos2(90.0, 300.0), vec2(110.0, 34.0)),
size: Default::default(),
edge: NavigationEdge::Bottom,
idle: NavigationIdle::Always,
active: false,
slots: vec![
slot(0, "fit", NavigationKind::Action, 100.0),
SlotView { alias: Some("test:styles"), ..slot(1, "styles", NavigationKind::Menu, 134.0) },
],
overflow: vec![slot(2, "zoom", NavigationKind::Action, 0.0), slot(3, "view", NavigationKind::Menu, 0.0)],
more: Rect::from_min_size(pos2(168.0, 304.0), vec2(26.0, 26.0)),
labels: Default::default(),
}
}
/// Styles closes on a pick; View stays open and has a submenu.
fn menu(id: &str) -> Menu {
match id {
"styles" => Menu { entries: vec![MenuEntry::Row(MenuRow::new("wire", "Wire", "", "test:wire"))], close_on_pick: true },
"view" => {
let mut preset = MenuRow::new("preset", "Preset", "", "test:preset");
preset.submenu = vec![MenuRow::new("fast", "Fast", "", "test:fast")];
Menu {
entries: vec![
MenuEntry::Heading("Show".into()),
MenuEntry::Row(MenuRow::new("grid", "Grid", "", "test:grid")),
MenuEntry::Separator,
MenuEntry::Row(preset),
],
close_on_pick: false,
}
}
_ => Menu::default(),
}
}
struct Rig {
ctx: egui::Context,
bar: Bar<'static>,
}
impl Rig {
fn new() -> Rig {
let rig = Rig { ctx: crate::fonts::test_context(), bar: bar() };
rig.frame(Vec::new());
rig
}
fn frame(&self, events: Vec<Event>) -> Option<NavAction> {
solidcraft_ui_registry::clear_handles();
let mut action = None;
crate::test_support::frame(
&self.ctx,
RawInput {
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0))),
events,
focused: true,
..Default::default()
},
|ui| action = show(ui, &self.bar, menu),
);
action
}
/// A press and release of `button` at `at`, then two quiet frames so a popup it opened is
/// measured and placed: what the release returned.
fn click_with(&self, at: egui::Pos2, button: PointerButton) -> Option<NavAction> {
let press = |pressed| vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }];
self.frame(vec![Event::PointerMoved(at)]);
let action = self.frame(press(true)).or(self.frame(press(false)));
self.frame(Vec::new());
self.frame(Vec::new());
action
}
fn click(&self, at: egui::Pos2) -> Option<NavAction> {
self.click_with(at, PointerButton::Primary)
}
fn click_target(&self, name: &str) -> Option<NavAction> {
let at = solidcraft_ui_registry::rect_of(name).expect("the target is drawn").center();
self.click(at)
}
fn drawn(&self, name: &str) -> bool {
solidcraft_ui_registry::rect_of(name).is_some()
}
}
#[test]
fn a_slot_or_a_row_of_its_menu_is_returned_and_a_closing_menu_closes() {
let rig = Rig::new();
assert_eq!(rig.click(pos2(113.0, 317.0)), Some(NavAction::Slot("fit".into())), "an action slot is returned, not run");
assert_eq!(rig.click(pos2(147.0, 317.0)), None, "a menu slot opens its menu");
assert_eq!(solidcraft_ui_registry::handle_at("test:styles"), Some(pos2(147.0, 317.0)), "a slot's alias names it too");
assert_eq!(rig.click_target("test:wire"), Some(NavAction::Item { menu: "styles".into(), item: "wire".into() }));
assert!(!rig.drawn("test:wire"), "a pick closes a menu that asks for it");
}
#[test]
fn more_returns_a_folded_slot_and_opens_a_folded_menu_that_stays_open() {
let rig = Rig::new();
assert_eq!(rig.click_target("nav:more"), None);
assert_eq!(rig.click_target("nav:slot:zoom"), Some(NavAction::Slot("zoom".into())), "a folded slot is returned");
assert!(!rig.drawn("nav:slot:zoom"), "and More closes");
rig.click_target("nav:more");
assert_eq!(rig.click_target("nav:slot:view"), None, "a folded menu slot opens its menu");
assert!(rig.drawn("test:grid") && !rig.drawn("nav:slot:zoom"), "in place of More");
assert_eq!(rig.click_target("test:grid"), Some(NavAction::Item { menu: "view".into(), item: "grid".into() }));
assert!(rig.drawn("test:grid"), "a menu that does not close on a pick stays open");
assert_eq!(rig.click_target("test:preset"), None, "a row with a submenu opens it");
assert_eq!(rig.click_target("test:fast"), Some(NavAction::Item { menu: "view".into(), item: "fast".into() }), "a submenu row by its own id");
}
#[test]
fn the_context_menus_return_customise_hide_reset_and_remove_with_the_slot_index() {
let rig = Rig::new();
for (id, item) in [("customise", ContextItem::Customise), ("hide", ContextItem::Hide), ("reset", ContextItem::Reset)] {
rig.click_with(pos2(94.0, 317.0), PointerButton::Secondary);
assert!(!rig.drawn("nav:context:remove"), "the bar's own menu");
assert_eq!(rig.click_target(&format!("nav:context:{id}")), Some(NavAction::Context(item)));
}
rig.click_with(pos2(147.0, 317.0), PointerButton::Secondary);
assert!(!rig.drawn("nav:context:hide"), "a slot's menu");
assert_eq!(rig.click_target("nav:context:remove"), Some(NavAction::Context(ContextItem::Remove(1))), "removes the slot at its stored index");
rig.click_target("nav:more");
rig.frame(vec![Event::PointerMoved(pos2(0.0, 0.0))]);
let folded = solidcraft_ui_registry::rect_of("nav:slot:view").expect("More is open").center();
rig.click_with(folded, PointerButton::Secondary);
assert_eq!(rig.click_target("nav:context:remove"), Some(NavAction::Context(ContextItem::Remove(3))), "a folded slot too");
}
}
#[test]
fn the_view_menu_slot_is_named_nav_view_on_the_bar_and_in_more() {
let mut h = crate::scenario::Harness::new();
h.app.ui.show_browser = false;
h.app.ui.show_timeline = false;
h.call("ui.prefs", json!({"navigation_bar":{"slots":["display","fit","home"]}}));
h.settle();
let on_bar = solidcraft_ui_registry::handle_at("nav:slot:display").expect("the View slot is on the bar");
assert_eq!(solidcraft_ui_registry::handle_at("nav:view"), Some(on_bar));
// Many slots before it in a narrow view fold the View slot into More.
h.call(
"ui.prefs",
json!({"navigation_bar":{"slots":["pan","fit","home","visual_style","grid","ground_shadow","ambient_occlusion","projection","spin","orbit","zoom","display"]}}),
);
h.call("ui.resize", json!({"width":300,"height":600}));
h.settle();
assert!(solidcraft_ui_registry::handle_at("nav:slot:display").is_none() && solidcraft_ui_registry::handle_at("nav:view").is_none(), "folded");
h.step(&json!({"click":{"handle":"nav:more"}})).unwrap();
let in_more = solidcraft_ui_registry::handle_at("nav:slot:display").expect("More lists the View slot");
assert_eq!(solidcraft_ui_registry::handle_at("nav:view"), Some(in_more));
}
+210 -322
View File
@@ -1,10 +1,10 @@
//! Measured single-row navigation bar. Commands own all view changes.
use crate::nav_bar::{self, Bar, BarLabels, ContextItem, Menu, MenuEntry, MenuRow, NavAction, SlotView};
use crate::navigation::NavCaps;
use crate::navigation_bar::{self, Labels, MenuDisplay, NavigationBar, Position, Slot};
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{self, Container, NavigationEdge, NavigationIdle, NavigationKind, Surface};
use crate::ui_kit::{self, NavigationEdge, NavigationKind};
use crate::{SolveApp, viewport::NavMode};
use egui::{Align2, Rect, pos2, vec2};
use egui::{Rect, pos2, vec2};
use serde_json::json;
use solidcraft_theme::Tokens;
@@ -259,21 +259,62 @@ fn activate(app: &mut SolveApp, slot: &Slot) {
}
}
fn tooltip(app: &SolveApp, response: egui::Response, slot: &Slot, hidden: Option<&str>, along: Rect, edge: NavigationEdge) -> egui::Response {
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
let label = if let Some(hidden) = hidden {
/// The slot's hover text: a hidden slot's reason, the current style, the sketch's lock, or its name.
fn tip(app: &SolveApp, slot: &Slot, hidden: Option<&str>, key: bool) -> String {
if let Some(hidden) = hidden {
app.tr(hidden).to_owned()
} else if slot.id == "visual_style" {
solidcraft_ui_text::format("navbar.value_tip", &[("name", app.tr(slot.label)), ("value", current_style(app))])
} else if slot.id == "projection" && projection_locked(app) {
app.tr("nav.perspective.sketch").to_owned()
} else if slot.id == "zoom" && key.is_some() {
} else if slot.id == "zoom" && key {
app.tr("navbar.zoom_hold_tip").to_owned()
} else {
app.tr(slot.label).to_owned()
};
ui_kit::navigation_tooltip(&response, along, edge, &label, key.as_deref());
response
}
}
/// Slot `slot`, at `index` of the stored list, as the bar component draws it.
fn slot_view(app: &SolveApp, bar: &BarLayout, slot: &'static Slot, index: usize, rect: Rect, editing: Option<(NavCaps, bool)>) -> SlotView<'static> {
let hidden = editing
.filter(|(caps, _)| !slot.available(*caps))
.map(|(_, render)| if render { "navbar.custom.hidden_render" } else { "navbar.custom.hidden_design" });
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
let value = slot.kind == NavigationKind::Menu
&& slot.id == "visual_style"
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue;
SlotView {
index,
id: slot.id,
kind: slot.kind,
icon: slot.current_icon(app.ui.visual_style),
text: slot_text(app, slot).to_owned(),
name: app.tr(slot.label).to_owned(),
tip: tip(app, slot, hidden, key.is_some()),
key,
selected: editing.is_none() && selected(app, slot.id),
enabled: editing.is_some() || !(slot.id == "projection" && projection_locked(app)),
hidden: hidden.is_some(),
show_text: if slot.kind == NavigationKind::Menu { bar.values && value } else { bar.labels },
rect,
// The View menu's slot, by the name older scenarios use.
alias: (slot.id == "display").then_some("nav:view"),
}
}
/// The measured row's slots, each with its index in the stored list (a slot the list no longer
/// holds is left out).
fn slot_views(app: &SolveApp, bar: &BarLayout, editing: Option<(NavCaps, bool)>) -> Vec<SlotView<'static>> {
let mut cursor = 0;
let mut views = Vec::new();
for (slot, rect) in &bar.slots {
let Some((index, _)) = app.preferences.navigation_bar.slots.iter().enumerate().skip(cursor).find(|(_, id)| id.as_str() == slot.id) else {
continue;
};
cursor = index + 1;
views.push(slot_view(app, bar, slot, index, *rect, editing));
}
views
}
pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
@@ -281,107 +322,73 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
return;
}
let Some(layout) = app.viewport.layout.as_ref().filter(|l| l.navigation.is_positive()) else { return };
let bar = measured(app, ui.ctx(), layout.content);
if !bar.rect.is_positive() {
let measured = measured(app, ui.ctx(), layout.content);
if !measured.rect.is_positive() {
return;
}
crate::scenario::publish_rect("nav:outer", bar.rect);
let menu_id = ui.scope_id().with("navigation_bar_menu");
let mut open = ui.data(|d| d.get_temp::<String>(menu_id)).unwrap_or_default();
let active = ui.rect_contains_pointer(bar.rect)
|| ui.rect_contains_pointer(crate::viewport::axis_rect(app, rect))
let active = ui.rect_contains_pointer(crate::viewport::axis_rect(app, rect))
|| app.viewport.axis_drag.is_some()
|| app.viewport.primary_nav.is_some()
|| app.viewport.orbit_from.is_some()
|| (app.viewport.mouse.is_some() && ui.input(|i| i.pointer.middle_down()))
|| app.viewport.modifier_nav.is_some()
|| app.viewport.spin
|| !open.is_empty();
let opacity = ui_kit::navigation_opacity(app.preferences.navigation_bar.idle, active);
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
let opacity = if app.preferences.navigation_bar.idle == NavigationIdle::Hide {
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
} else {
opacity
|| app.viewport.spin;
let prefs = &app.preferences.navigation_bar;
let bar = Bar {
rect: measured.rect,
size: prefs.size,
edge: prefs.edge,
idle: prefs.idle,
active,
slots: slot_views(app, &measured, None),
overflow: measured
.overflow
.iter()
.filter_map(|slot| Some(slot_view(app, &measured, slot, prefs.slots.iter().position(|id| id == slot.id)?, Rect::NOTHING, None)))
.collect(),
more: measured.more,
labels: BarLabels {
more: app.tr("navbar.more").to_owned(),
customise: app.tr("navbar.custom.customise").to_owned(),
hide: app.tr("navbar.custom.hide").to_owned(),
reset: app.tr("navbar.custom.reset").to_owned(),
remove: app.tr("navbar.custom.remove_bar").to_owned(),
},
};
let mut anchor = bar.more;
ui.scope(|ui| {
ui.set_opacity(opacity);
for (index, slot, response) in paint_row(app, ui, &bar, &open, None) {
let bounds = response.rect;
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
context_menu(app, &response, Some(index));
if response.clicked() && slot.kind != NavigationKind::Separator {
if slot.kind == NavigationKind::Menu {
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
} else {
activate(app, slot);
}
}
if slot.id == "display" {
crate::scenario::publish_handle("nav:view", bounds.center());
}
if open == slot.id {
anchor = bounds;
}
}
if bar.more.is_positive() {
let response = ui_kit::navigation_button(
ui,
bar.more,
ui_kit::NavigationButton {
id: "more",
kind: NavigationKind::Menu,
icon: "dots",
label: app.tr("navbar.more"),
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
labels: false,
menu: false,
},
);
ui_kit::navigation_tooltip(&response, bar.rect, app.preferences.navigation_bar.edge, app.tr("navbar.more"), None);
crate::scenario::publish_target("nav:more", response.rect);
context_menu(app, &response, None);
if response.clicked() {
open = if open == "more" { String::new() } else { "more".to_owned() };
}
}
});
context_menu(app, &background, None);
if !open.is_empty() && anchor.is_positive() {
let child_popup_open = egui::Popup::is_any_open(ui.ctx());
let ctx = ui.ctx().clone();
// Menus open into the view: up from a bar at the bottom, down from one at the top.
let (pivot, at) = match app.preferences.navigation_bar.edge {
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
};
let menu = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
&ctx,
|ui| {
ui_kit::navigation_menu(ui, if open == "more" { ui_kit::NAV_MORE_WIDTH } else { ui_kit::NAV_POPUP_WIDTH }, |ui| {
match open.as_str() {
"visual_style" => {
if style_menu(app, ui) {
open.clear();
}
}
"display" => display_menu(app, ui),
"more" => overflow_menu(app, ui, &bar.overflow, &mut open),
_ => open.clear(),
}
});
},
);
let outside = !child_popup_open
&& ui.input(|i| {
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !menu.response.rect.contains(p) && !bar.rect.contains(p))
});
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
open.clear();
}
let action = nav_bar::show(ui, &bar, |id| menu(app, id));
if let Some(action) = action {
run(app, action);
// Rows of a menu that stays open show the new state on the next frame, without new input.
ui.ctx().request_repaint();
}
}
/// Run what the bar returned.
fn run(app: &mut SolveApp, action: NavAction) {
match action {
NavAction::Slot(id) => {
if let Some(slot) = navigation_bar::find(&id) {
activate(app, slot);
}
}
NavAction::Context(ContextItem::Customise) => {
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
}
NavAction::Context(ContextItem::Hide) => {
let _ = app.run("view.navigation_bar", json!({"visible":false}));
}
NavAction::Context(ContextItem::Reset) => {
app.preferences.navigation_bar = NavigationBar::default();
let _ = app.run("view.navigation_bar", json!({"visible":true}));
}
NavAction::Context(ContextItem::Remove(index)) => {
let _ = app.preferences.navigation_bar.remove(index);
}
NavAction::Item { menu, item } if menu == "visual_style" => {
let _ = app.run("view.visual_style", json!({"style":item}));
}
NavAction::Item { item, .. } => display_pick(app, &item),
}
ui.data_mut(|d| d.insert_temp(menu_id, open));
}
pub(crate) fn current_style(app: &SolveApp) -> &str {
@@ -409,250 +416,131 @@ pub(crate) fn paint_row(
open: &str,
editing: Option<(Option<usize>, NavCaps, bool)>,
) -> Vec<(usize, &'static Slot, egui::Response)> {
ui_kit::navigation_surface(ui, bar.rect, app.preferences.navigation_bar.size, editing.is_some());
let mut responses = Vec::new();
let mut cursor = 0;
for (slot, bounds) in &bar.slots {
let Some((index, _)) = app.preferences.navigation_bar.slots.iter().enumerate().skip(cursor).find(|(_, id)| id.as_str() == slot.id) else {
continue;
};
cursor = index + 1;
let response = ui
.push_id(index, |ui| {
let hidden = editing.is_some_and(|(_, caps, _)| !slot.available(caps));
ui.scope(|ui| {
if hidden {
ui.set_opacity(ui_kit::NAV_HIDDEN_OPACITY);
} else if editing.is_none() && slot.id == "projection" && projection_locked(app) {
ui.set_opacity(ui.visuals().disabled_alpha);
}
if slot.kind == NavigationKind::Separator {
ui_kit::navigation_separator(ui, *bounds);
ui.interact(
*bounds,
ui.scope_id().with("separator"),
if editing.is_some() { egui::Sense::click_and_drag() } else { egui::Sense::click() },
)
} else {
let button = ui_kit::NavigationButton {
id: slot.id,
kind: slot.kind,
icon: slot.current_icon(app.ui.visual_style),
label: slot_text(app, slot),
selected: if editing.is_some() { false } else { selected(app, slot.id) || open == slot.id },
labels: if slot.kind == NavigationKind::Menu {
bar.values
&& slot.id == "visual_style"
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue
} else {
bar.labels
},
menu: slot.kind == NavigationKind::Menu,
};
if editing.is_some() {
ui_kit::navigation_button_editable(ui, *bounds, button, true)
} else {
ui_kit::navigation_button(ui, *bounds, button)
}
}
})
.inner
})
.inner;
let mut hidden = None;
if let Some((selected, caps, render)) = editing {
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected == Some(index), app.tr(slot.label)));
ui_kit::navigation_edit_outline(ui, *bounds, !slot.available(caps), selected == Some(index));
if !slot.available(caps) {
hidden = Some(if render { "navbar.custom.hidden_render" } else { "navbar.custom.hidden_design" });
}
} else {
response.widget_info(|| {
egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected(app, slot.id) || open == slot.id, app.tr(slot.label))
});
}
// The settings' preview keeps its tips above, wherever the bar sits in the view.
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
responses.push((index, *slot, tooltip(app, response, slot, hidden, bar.rect, edge)));
let mut views = slot_views(app, bar, editing.map(|(_, caps, render)| (caps, render)));
for view in &mut views {
view.selected |= editing.is_none() && open == view.id;
}
responses
// The settings' preview keeps its tips above, wherever the bar sits in the view.
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
let responses = nav_bar::paint_slots(ui, bar.rect, app.preferences.navigation_bar.size, edge, &views, editing.map(|(selected, _, _)| selected));
views.iter().zip(responses).filter_map(|(view, response)| Some((view.index, navigation_bar::find(view.id)?, response))).collect()
}
pub(crate) fn context_menu(app: &mut SolveApp, response: &egui::Response, index: Option<usize>) {
ui_kit::navigation_context(response, |ui| {
for id in if index.is_none() { &["customise", "hide", "reset"][..] } else { &[][..] } {
let key = format!("navbar.custom.{id}");
let item = ui_kit::NavigationMenuRow::new(id, app.tr(&key), "").show(ui);
crate::scenario::publish_target(&format!("nav:context:{id}"), item.rect);
if item.clicked() {
match *id {
"customise" => {
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
}
"hide" => {
let _ = app.run("view.navigation_bar", json!({"visible":false}));
}
_ => {
app.preferences.navigation_bar = NavigationBar::default();
let _ = app.run("view.navigation_bar", json!({"visible":true}));
}
}
ui.close();
}
}
if let Some(index) = index {
let item = ui_kit::NavigationMenuRow::new("remove", app.tr("navbar.custom.remove_bar"), "").show(ui);
crate::scenario::publish_target("nav:context:remove", item.rect);
if item.clicked() {
let _ = app.preferences.navigation_bar.remove(index);
ui.close();
}
}
});
/// The rows of the menu slot `id` opens (none for an unknown one, which closes it).
fn menu(app: &SolveApp, id: &str) -> Menu {
match id {
"visual_style" => style_menu(app),
"display" => display_menu(app),
_ => Menu::default(),
}
}
fn style_menu(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
let mut chosen = false;
for (index, style) in navigation_bar::VISUAL_STYLES.iter().map(|(style, _)| *style).enumerate() {
let label = format!("navbar.style.{style}");
let mut item = ui_kit::NavigationMenuRow::new(style, app.tr(&label), navigation_bar::visual_style_icon(index as u8));
item.selected = app.ui.visual_style == index as u8;
let response = item.show(ui);
crate::scenario::publish_target(&format!("nav:style:{style}"), response.rect);
if response.clicked() {
let _ = app.run("view.visual_style", json!({"style":style}));
chosen = true;
}
}
ui_kit::navigation_menu_separator(ui);
let mut later = ui_kit::NavigationMenuRow::new("translucent", app.tr("navbar.translucent"), "style_translucent");
/// Visual style: the four styles, then Translucent (later); a pick closes the menu.
fn style_menu(app: &SolveApp) -> Menu {
let mut entries: Vec<MenuEntry> = navigation_bar::VISUAL_STYLES
.iter()
.enumerate()
.map(|(index, (style, _))| {
let mut row = MenuRow::new(
*style,
app.tr(&format!("navbar.style.{style}")),
navigation_bar::visual_style_icon(index as u8),
format!("nav:style:{style}"),
);
row.selected = app.ui.visual_style == index as u8;
MenuEntry::Row(row)
})
.collect();
entries.push(MenuEntry::Separator);
let mut later = MenuRow::new("translucent", app.tr("navbar.translucent"), "style_translucent", "");
later.enabled = false;
later.detail = Some(app.tr("navbar.later"));
later.show(ui);
chosen
later.detail = Some(app.tr("navbar.later").to_owned());
entries.push(MenuEntry::Row(later));
Menu { entries, close_on_pick: true }
}
fn overflow_menu(app: &mut SolveApp, ui: &mut egui::Ui, slots: &[&Slot], open: &mut String) {
for slot in slots {
if slot.kind == NavigationKind::Separator {
let start = ui.next_widget_position();
ui_kit::navigation_menu_separator(ui);
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
continue;
}
let mut item = ui_kit::NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
item.selected = selected(app, slot.id);
}
let response = item.show(ui);
let index = app.preferences.navigation_bar.slots.iter().position(|id| id == slot.id);
context_menu(app, &response, index);
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
if slot.id == "display" {
crate::scenario::publish_handle("nav:view", response.rect.center());
}
if tooltip(app, response.clone(), slot, None, response.rect, NavigationEdge::Bottom).clicked() {
if slot.kind == NavigationKind::Menu {
*open = slot.id.to_owned();
} else {
activate(app, slot);
open.clear();
}
}
}
}
fn display_menu(app: &mut SolveApp, ui: &mut egui::Ui) {
use ui_kit::{NavigationMenuRow, navigation_menu_heading as heading, navigation_menu_separator as separator};
heading(ui, app.tr("navbar.graphics"));
/// The View menu: graphics preset, what the view shows, projection, cursor and the assistant.
fn display_menu(app: &SolveApp) -> Menu {
let row = |id: &str, label: &str, icon: &str, target: &str, selected: bool| {
let mut row = MenuRow::new(id, label, icon, target);
row.selected = selected;
row
};
let mut entries = vec![MenuEntry::Heading(app.tr("navbar.graphics").to_owned())];
let current = crate::prefs::graphics_preset(&app.preferences);
let detail = format!("ui.graphics_{current}");
let mut preset = NavigationMenuRow::new("preset", app.tr("navbar.preset"), "");
preset.detail = Some(app.tr(&detail));
preset.submenu = true;
let response = preset.show(ui);
crate::scenario::publish_target("view:graphics_preset", response.rect);
Container::new(ui.ctx(), Surface::Popup).menu(&response).frame(ui_kit::navigation_popup_frame()).show(|ui| {
ui.set_width(ui_kit::NAV_POPUP_WIDTH - 2.0 * (f32::from(ui_kit::NAV_POPUP_PADDING) + ui_kit::NAV_BORDER));
ui.spacing_mut().item_spacing.y = 0.0;
for (id, label) in [("performance", "ui.graphics_performance"), ("quality", "ui.graphics_quality")] {
let mut item = NavigationMenuRow::new(id, app.tr(label), "");
item.selected = current == id;
let response = item.show(ui);
crate::scenario::publish_target(&format!("view:graphics_preset:{id}"), response.rect);
if response.clicked() {
let _ = app.run("view.graphics_preset", json!({"preset":id}));
ui.close();
}
}
});
separator(ui);
heading(ui, app.tr("navbar.show"));
let mut preset = MenuRow::new("preset", app.tr("navbar.preset"), "", "view:graphics_preset");
preset.detail = Some(app.tr(&format!("ui.graphics_{current}")).to_owned());
preset.submenu = [("performance", "ui.graphics_performance"), ("quality", "ui.graphics_quality")]
.into_iter()
.map(|(id, label)| row(id, app.tr(label), "", &format!("view:graphics_preset:{id}"), current == id))
.collect();
entries.extend([MenuEntry::Row(preset), MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.show").to_owned())]);
for id in ["grid", "ground_shadow", "ambient_occlusion"] {
let Some(slot) = navigation_bar::find(id) else { continue };
let mut item = NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
item.selected = selected(app, slot.id);
let response = item.show(ui);
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
if response.clicked() {
activate(app, slot);
}
entries.push(MenuEntry::Row(row(
slot.id,
app.tr(slot.label),
slot.current_icon(app.ui.visual_style),
&format!("view:{id}"),
selected(app, slot.id),
)));
}
separator(ui);
heading(ui, app.tr("navbar.projection_heading"));
entries.extend([MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.projection_heading").to_owned())]);
for (perspective, key) in [(false, "navbar.orthographic"), (true, "navbar.perspective")] {
let mut item = NavigationMenuRow::new(key, app.tr(key), "perspective");
item.selected = app.ui.perspective == perspective;
let mut item = row(
key,
app.tr(key),
"perspective",
if perspective { "view:projection" } else { "view:orthographic" },
app.ui.perspective == perspective,
);
item.enabled = !projection_locked(app);
let response = item.show(ui);
let response = if projection_locked(app) { response.backing_on_hover_text(app.tr("nav.perspective.sketch")) } else { response };
crate::scenario::publish_target(if perspective { "view:projection" } else { "view:orthographic" }, response.rect);
if response.clicked() && app.ui.perspective != perspective {
toggle_projection(app);
}
item.tip = projection_locked(app).then(|| app.tr("nav.perspective.sketch").to_owned());
entries.push(MenuEntry::Row(item));
}
separator(ui);
entries.push(MenuEntry::Separator);
for (id, label, on) in [
("auto_ortho_faces", "Perspective while orbiting, ortho on faces", app.ui.auto_ortho_faces),
("tool_cursor", "Show tool at cursor", app.ui.tool_cursor),
] {
let mut row = NavigationMenuRow::new(id, solidcraft_ui_text::tr_label(label), "select");
row.selected = on;
let response = row.show(ui);
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
if response.clicked() {
match id {
"auto_ortho_faces" => app.ui.auto_ortho_faces = !on,
_ => app.ui.tool_cursor = !on,
}
}
entries.push(MenuEntry::Row(row(id, solidcraft_ui_text::tr_label(label), "select", &format!("view:{id}"), on)));
}
// The View angles readout under the orientation gizmo (#6032).
let mut angles = NavigationMenuRow::new("view_angles", app.tr("ui.view_angles"), "select");
angles.selected = app.preferences.show_view_angles;
let response = angles.show(ui).backing_on_hover_text(app.tr("ui.view_angles_help"));
crate::scenario::publish_target("view:view_angles", response.rect);
if response.clicked() {
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
}
separator(ui);
heading(ui, app.tr("navbar.assistant"));
let mut cursor = NavigationMenuRow::new("agent_cursor", app.tr("navbar.agent_cursor"), "select");
cursor.selected = app.ui.agent_cursor.show;
let response = cursor.show(ui);
crate::scenario::publish_target("view:agent_cursor", response.rect);
if response.clicked() {
app.ui.agent_cursor.show = !app.ui.agent_cursor.show;
}
let mut angles = row("view_angles", app.tr("ui.view_angles"), "select", "view:view_angles", app.preferences.show_view_angles);
angles.tip = Some(app.tr("ui.view_angles_help").to_owned());
entries.extend([MenuEntry::Row(angles), MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.assistant").to_owned())]);
entries.push(MenuEntry::Row(row("agent_cursor", app.tr("navbar.agent_cursor"), "select", "view:agent_cursor", app.ui.agent_cursor.show)));
if crate::agent_chat::shown(app) {
let mut chat = NavigationMenuRow::new("agent_chat", app.tr("navbar.agent_chat"), "agent_chat");
chat.selected = app.agent_chat.open;
let response = chat.show(ui);
crate::scenario::publish_target("view:agent_chat", response.rect);
if response.clicked() {
entries.push(MenuEntry::Row(row("agent_chat", app.tr("navbar.agent_chat"), "agent_chat", "view:agent_chat", app.agent_chat.open)));
}
Menu { entries, close_on_pick: false }
}
/// A View menu pick.
fn display_pick(app: &mut SolveApp, item: &str) {
match item {
"performance" | "quality" => {
let _ = app.run("view.graphics_preset", json!({"preset":item}));
}
"navbar.orthographic" | "navbar.perspective" => {
if app.ui.perspective != (item == "navbar.perspective") {
toggle_projection(app);
}
}
"auto_ortho_faces" => app.ui.auto_ortho_faces = !app.ui.auto_ortho_faces,
"tool_cursor" => app.ui.tool_cursor = !app.ui.tool_cursor,
"view_angles" => {
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
}
"agent_cursor" => app.ui.agent_cursor.show = !app.ui.agent_cursor.show,
"agent_chat" => {
app.agent_chat.open = !app.agent_chat.open;
app.preferences.agent_chat_minimized &= !app.agent_chat.open;
}
id => {
if let Some(slot) = navigation_bar::find(id) {
activate(app, slot);
}
}
}
}
+3 -1
View File
@@ -682,7 +682,9 @@ impl AppPanelRuntime {
if (self.runtime.environment() != &before || self.runtime.layout().inspect().revision != revision)
&& let Some(host) = &mut self.host
{
host.invalidate_context();
// Presentation metadata can revise an unrelated panel.
// Scope and focused-owner checks decide input ownership.
host.invalidate_geometry();
host.reconcile_scope(&self.runtime);
}
}
+12 -1
View File
@@ -110,6 +110,17 @@ impl Product {
}
fn presentation(self, app: &crate::SolveApp, play_button: bool) -> PanelPresentation {
let mut presentation = self.empty_presentation();
if self == Self::Assistant {
let waiting = app.agent_chat.waiting();
let state = app.agent_chat.state();
presentation.status = Some(if waiting > 0 {
solidcraft_ui_text::format("ui.assistant_waiting", &[("count", &waiting.to_string())])
} else {
state.label().into()
});
presentation.attention = waiting > 0
|| matches!(state, crate::agent_chat::State::NeedsAuth | crate::agent_chat::State::Exited | crate::agent_chat::State::Failed);
}
presentation.actions = match self {
Self::History => crate::ui_kit::HistoryTransport::shown(play_button)
.iter()
@@ -854,7 +865,7 @@ impl FramePanelContent for ProductionFrame<'_> {
Product::Browser | Product::RenderBrowser => crate::browser::panel_contents(self.app, ui),
Product::History if context.flow == Flow::Wide => crate::timeline::panel_inline(self.app, ui),
Product::History => crate::timeline_left::panel_contents(self.app, ui),
Product::Assistant => crate::agent_chat::shared_content(self.app, ui),
Product::Assistant => crate::agent_chat::shared_content(self.app, ui, context.flow),
}
let capture = ui.ctx().data_mut(|data| data.remove_temp::<Option<FocusCapture>>(focus_capture_id()).flatten());
if context.document.as_ref().map(|document| document.0.as_str()) != Some(self.app.session.document_id.as_str()) {
+86
View File
@@ -12893,6 +12893,92 @@ fn content_focus_bookmark_restores_caret_after_explicit_collapse_and_focus() {
}
}
#[test]
fn editor_focus_survives_floating_sizing_unless_a_live_foreign_control_takes_it() {
for scale in [1.0, 2.0] {
for foreign_focus in [false, true] {
let (mut content, trace) = review_cohort_host(|_| {});
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
if foreign_focus {
raw.prime_foreign_window();
}
let editor = trace.borrow().editors.last().unwrap().0;
raw.ctx.memory_mut(|memory| memory.request_focus(editor));
raw.frame(Vec::new(), false);
raw.frame_late(vec![egui::Event::Text(" retained reparent selection".into())], false, |_, _| {});
raw.frame(Vec::new(), false);
let mut state = egui::TextEdit::load_state(&raw.ctx, editor).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
state.store(&raw.ctx, editor);
raw.frame(Vec::new(), false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
let caret = egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range();
assert!(!caret.unwrap().is_empty());
let draft = trace.borrow().editors.last().unwrap().3.clone();
let key = ControllerKey { instance: id("wrapping-editor"), owner: raw.content.runtime.instance_owner(&id("wrapping-editor")).unwrap() };
let renders = raw.content.probe.state(&key).unwrap().renders;
if foreign_focus {
raw.foreign_cover = Some(egui::Rect::from_min_size(egui::pos2(1000.0, 600.0), egui::vec2(180.0, 80.0)));
raw.foreign_takes_focus = true;
}
raw.content
.dispatch(vec![Mutation::Move {
instance: id("wrapping-editor"),
destination: Destination::NewSection {
section: section("reparented-editor"),
index: 0,
location: SectionLocation::Floating { origin: Point { x: 400.0, y: 180.0 } },
size: Size { width: 400.0, height: 320.0 },
},
}])
.unwrap();
raw.frame(Vec::new(), false);
assert_eq!(raw.content.probe.state(&key).unwrap().renders, renders, "the new Area sizing pass must not run a hidden body");
let foreign = foreign_focus.then(|| raw.foreign_responses.last().unwrap().id);
assert_eq!(
raw.ctx.memory(|memory| memory.focused()),
foreign,
"sizing removes the editor; only the real late foreign widget may take focus"
);
// Stop requesting focus: subsequent passes must retain the person's
// choice without repeatedly forcing it after host restoration.
raw.foreign_takes_focus = false;
for _ in 0..2 {
raw.frame(Vec::new(), false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), foreign.or(Some(editor)));
}
assert!(raw.content.probe.state(&key).unwrap().renders > renders);
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
if foreign_focus {
// A later deliberate focus command may return to the retained
// selection. It is separate from the Move tested above.
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
raw.host.note_reply(&reply);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
}
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range(), caret);
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
assert_eq!(raw.content.commands.calls, 0);
// A retained bookmark must not keep its editor focused after the
// owning content becomes unavailable, even before another paint.
trace.borrow_mut().unavailable.insert(key.clone());
raw.content.tick().unwrap();
assert_ne!(raw.content.runtime.layout().availability(&key.instance), Availability::Available);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
raw.host.reconcile_scope(&raw.content.runtime);
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range(), caret);
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
}
}
}
#[test]
fn custom_header_action_reports_enabled_and_pressed_state_from_retained_presentation() {
let (mut content, trace) = review_cohort_host(|_| {});
+42 -8
View File
@@ -226,6 +226,17 @@ struct ContentFocus {
selection: Option<egui::text::CCursorRange>,
}
impl ContentFocus {
fn logically_presented(&self, runtime: &PanelRuntime) -> bool {
let service = runtime.layout();
self.scope == runtime_scope(runtime)
&& runtime.instance_owner(&self.key.instance).is_ok_and(|owner| owner == self.key.owner)
&& service.availability(&self.key.instance) == Availability::Available
&& service.layout().instance(&self.key.instance).is_ok_and(|instance| instance.requested_open)
&& service.layout().containing(&self.key.instance).is_ok_and(|section| {
let availability: AvailabilityMap = section.tabs.iter().map(|instance| (instance.clone(), service.availability(instance))).collect();
!section.collapsed && presented_tab(section, &availability) == Some(&self.key.instance)
})
}
fn restore_selection(&self, ctx: &egui::Context) {
if let Some(selection) = self.selection
&& let Some(mut state) = egui::TextEdit::load_state(ctx, self.id)
@@ -600,6 +611,16 @@ impl WorkspacePanelHost {
}
self.scope = Some(identity);
}
if let Some(bookmark) = &self.content_focus
&& self.owned_focus == Some(bookmark.id)
&& !bookmark.logically_presented(runtime)
{
self.restore_content_focus = false;
self.owned_focus = None;
if let Some(ctx) = &self.egui_context {
ctx.memory_mut(|memory| memory.surrender_focus(bookmark.id));
}
}
}
pub(crate) fn note_reply(&mut self, reply: &LayoutReply) {
for effect in &reply.effects {
@@ -935,7 +956,7 @@ impl WorkspacePanelHost {
// the intended resolved geometry, but never publish it as actual paint
// or an input/occlusion shield, even if no further egui pass is allowed.
let mut unpainted_instances = Vec::new();
for (layer, bounds) in &unpainted {
for (layer, bounds, sizing) in &unpainted {
let hidden_sections: Vec<_> = frame
.layout
.sections
@@ -946,6 +967,16 @@ impl WorkspacePanelHost {
.iter()
.flat_map(|section| std::iter::once(section.instance.clone()).chain(section.tabs.iter().map(|tab| tab.instance.clone())))
.collect();
// A new Area measures before it paints. Preserve only the editor
// that still owns focus; restore it after a fresh response returns.
if *sizing && ui.is_enabled() && ui.is_visible() && ui.input(|input| input.focused) && !ui_kit::Container::modal_input_blocked(ui.ctx()) {
self.restore_content_focus |= self.content_focus.as_ref().is_some_and(|bookmark| {
self.owned_focus == Some(bookmark.id)
&& ui.memory(|memory| memory.focused()) == Some(bookmark.id)
&& bookmark.logically_presented(runtime)
&& hidden_sections.iter().any(|section| section.instance == bookmark.key.instance && !section.collapsed)
});
}
unpainted_instances.extend(hidden_instances.iter().cloned());
let clipped = logical_rect(egui_rect(*bounds).intersect(egui_rect(frame.layout.capacity)));
// Remove one occurrence: a visible card can occupy the same rect.
@@ -1032,6 +1063,12 @@ impl WorkspacePanelHost {
}
}
}
let logical_focus_eligible = self.restore_content_focus
&& ui.input(|input| input.focused)
&& self.content_focus.as_ref().is_some_and(|bookmark| bookmark.logically_presented(runtime));
// A passive return cannot outlive deliberate hiding or owner/focus loss.
// A modal may still wait while that logical presentation remains valid.
self.restore_content_focus &= logical_focus_eligible;
if self.restore_content_focus && !ui_kit::Container::modal_input_blocked(ui.ctx()) {
let current = ui.memory(|memory| memory.focused());
let deliberate = current.is_some_and(|id| ui.ctx().read_response(id).is_some_and(|response| response.gained_focus()));
@@ -1040,11 +1077,8 @@ impl WorkspacePanelHost {
} else if current.is_none()
&& let Some(bookmark) = &self.content_focus
{
let eligible = bookmark.scope == scope
&& runtime.instance_owner(&bookmark.key.instance).is_ok_and(|owner| owner == bookmark.key.owner)
&& runtime.layout().availability(&bookmark.key.instance) == Availability::Available
&& frame.layout.sections.iter().any(|section| section.instance == bookmark.key.instance && !section.collapsed)
&& self.content_reports.get(&bookmark.id).is_some_and(|report| report.key == bookmark.key && report.scope == bookmark.scope);
let eligible =
self.content_reports.get(&bookmark.id).is_some_and(|report| report.key == bookmark.key && report.scope == bookmark.scope);
if eligible {
bookmark.restore_selection(ui.ctx());
ui.memory_mut(|memory| memory.request_focus(bookmark.id));
@@ -2196,7 +2230,7 @@ struct WorkspaceContentHost<'a> {
motion: Motion,
commands: &'a mut Vec<HostCommand>,
allow_actions: bool,
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect)>,
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect, bool)>,
}
impl PanelHost for WorkspaceContentHost<'_> {
@@ -2247,7 +2281,7 @@ impl PanelHost for WorkspaceContentHost<'_> {
matches!(appearance.contrast, ContrastOutcome::RetainReadableFallback { .. }).then(|| style.palette(appearance)),
|ui, content_clip| {
if (section.peek || section.zone == Zone::Floating) && (!ui.is_visible() || ui.is_sizing_pass()) {
self.unpainted.push((ui.layer_id(), section.outer));
self.unpainted.push((ui.layer_id(), section.outer, ui.is_sizing_pass()));
return;
}
// A surface response provides semantic focus and owns only the actual
@@ -350,13 +350,14 @@ pub(crate) fn paint_navigation_axes(p: &Painter, rect: Rect, axes: &[NavigatorAx
let t = Tokens::get();
let c = rect.center();
p.circle(c, 46.0 * scale, t.overlay.gamma_multiply(0.35), Stroke::new(scale, t.border.gamma_multiply(0.75)));
let mut tips: Vec<_> = axes.iter().map(|axis| (axis, c + axis.screen * (38.0 * 0.84 * scale))).collect();
let mut tips: Vec<_> =
axes.iter().map(|axis| (axis, c + crate::view_compass::tip_offset([f64::from(axis.screen.x), f64::from(axis.screen.y)]) * scale)).collect();
tips.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth));
for (index, (axis, at)) in tips.iter().enumerate() {
if index == 3 {
p.circle(c, 3.6 * scale, t.text.gamma_multiply(0.9), Stroke::new(1.4 * scale, t.panel));
}
crate::icons::navigation::axis_tip_scaled(&p, c, axis.name, &axis.direction.into(), *at, axis.depth, false, t, scale);
crate::icons::navigation::axis_tip_scaled(&p, c, axis.name, axis.direction, *at, axis.depth, false, t, scale);
}
}
+317
View File
@@ -0,0 +1,317 @@
//! The view compass as a component: the six signed world axes drawn as the camera's view axes
//! pose them, a Home chip, and a drag anywhere on it to orbit. It takes plain inputs
//! ([`Compass`], [`ViewAxes`]) and returns the [`ViewAction`] chosen, and [`orbit`] returns a drag's
//! [`OrbitStep`]; the app turns the camera.
//! Every tip publishes its handle (`axis_view:<axis>`) to the widget registry while it is drawn.
use crate::icons::navigation::AXIS_RADIUS;
use crate::ui_kit::BackingTooltipExt as _;
use egui::{Pos2, Rect, Sense, Stroke, pos2, vec2};
use solidcraft_theme::Tokens;
/// How far a tip is from the centre, before [`ART_SCALE`].
const AXIS_LENGTH: f64 = 38.0;
/// The artwork sits inside the pointer reach so the compass keeps breathing room in its circle.
const ART_SCALE: f64 = 0.84;
/// The compass circle's radius: anywhere inside drags the view.
const RADIUS: f32 = 49.5;
/// The six signed world axes, by name.
pub const AXES: [(&str, [f64; 3]); 6] = [
("x", [1.0, 0.0, 0.0]),
("-x", [-1.0, 0.0, 0.0]),
("y", [0.0, 1.0, 0.0]),
("-y", [0.0, -1.0, 0.0]),
("z", [0.0, 0.0, 1.0]),
("-z", [0.0, 0.0, -1.0]),
];
/// The camera's view axes in world space.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewAxes {
pub right: [f64; 3],
pub up: [f64; 3],
/// Toward the viewer.
pub back: [f64; 3],
}
impl ViewAxes {
/// World direction `d` as the camera poses it: its parts along the screen's right and down,
/// and its depth toward the viewer.
pub fn pose(&self, d: [f64; 3]) -> ([f64; 2], f64) {
([dot(d, self.right), -dot(d, self.up)], dot(d, self.back))
}
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// One signed axis as drawn: its name (`"x"`, `"-x"`, ... `"-z"`), world direction, tip on screen
/// and depth along the camera's back.
pub type Tip = (&'static str, [f64; 3], Pos2, f64);
/// What the user chose on the compass: a tip or Home.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ViewAction {
/// Look along an axis: a tip was clicked (`direction`: the tip's world direction).
Snap { axis: &'static str, direction: [f64; 3] },
/// The Home chip was clicked.
Home,
}
/// One frame's step of a drag on a view navigator: `(dx, dy)` screen points (`snap`: Shift was
/// held).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct OrbitStep {
pub dx: f64,
pub dy: f64,
pub snap: bool,
}
/// One frame of the compass.
#[derive(Clone, Debug)]
pub struct Compass {
/// The area it may paint in.
pub clip: Rect,
pub centre: Pos2,
pub axes: ViewAxes,
/// The pointer, when it may act on the compass (the app leaves out a pointer over a floating
/// window or with the view disabled).
pub hover: Option<Pos2>,
/// A drag on it is turning the view (no tip lights while it does).
pub orbiting: bool,
/// The tip's hover text mentions holding Shift to snap.
pub snap_hint: bool,
pub home_label: String,
}
/// Where a tip is drawn from the centre, for a direction posed on screen as `[right, down]`
/// ([`ViewAxes::pose`]); a figure of the compass scales it.
pub fn tip_offset([x, y]: [f64; 2]) -> egui::Vec2 {
vec2((x * AXIS_LENGTH * ART_SCALE) as f32, (y * AXIS_LENGTH * ART_SCALE) as f32)
}
/// The six signed axes as `axes` poses them around `c`, back to front so overlapping tips have
/// one owner.
pub fn tips(axes: &ViewAxes, c: Pos2) -> Vec<Tip> {
let mut tips: Vec<Tip> = AXES
.into_iter()
.map(|(name, d)| {
let (screen, depth) = axes.pose(d);
(name, d, c + tip_offset(screen), depth)
})
.collect();
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
tips
}
/// The front-most tip within reach of `p`.
pub fn hit(tips: &[Tip], p: Pos2) -> Option<usize> {
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
}
/// Draw the compass; a tip or Home clicked this frame (a drag on it is [`orbit`]'s).
pub fn show(ui: &mut egui::Ui, compass: &Compass) -> Option<ViewAction> {
let t = Tokens::get();
let c = compass.centre;
let tips = tips(&compass.axes, c);
let hover = compass.hover;
let hovered = if compass.orbiting { None } else { hover.and_then(|p| hit(&tips, p)) };
let painter = ui.painter_at(compass.clip);
let mut action = None;
// The circle turns fully opaque under the pointer.
let in_circle = hover.is_some_and(|p| p.distance(c) <= RADIUS);
let fill = if in_circle || compass.orbiting { 1.0 } else { 0.35 };
painter.circle(c, RADIUS, t.overlay.gamma_multiply(fill), Stroke::new(1.0, t.border.gamma_multiply(0.75)));
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
if i == 3 {
// The origin dot sits behind the three front axes and over the three back ones.
painter.circle(c, 3.6, t.text.gamma_multiply(0.9), Stroke::new(1.4, t.panel));
}
crate::icons::navigation::axis_tip(&painter, c, name, *d, *at, *depth, hovered == Some(i), t);
solidcraft_ui_registry::publish_handle(&format!("axis_view:{name}"), *at);
}
solidcraft_ui_registry::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
if let Some(i) = hovered
&& let Some((name, d, _, _)) = tips.get(i)
{
solidcraft_ui_registry::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.scope_id().with("axis_view_tip"), Sense::hover())
.backing_on_hover_text(format!(
"View from {}. Click again to reverse. Drag to orbit{}.",
name.to_uppercase(),
if compass.snap_hint { "; hold Shift to snap" } else { "" }
));
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
if ui.input(|i| i.pointer.primary_clicked()) {
action = Some(ViewAction::Snap { axis: name, direction: *d });
}
}
let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0));
solidcraft_ui_registry::publish_target("axis_view:home", home);
let hr = ui.interact(home, ui.scope_id().with("axis_view_home"), Sense::click());
// The home button sits in its own small round chip.
painter.circle(home.center(), 12.0, t.overlay.gamma_multiply(if hr.hovered() { 0.9 } else { 0.5 }), Stroke::new(1.0, t.border));
crate::icons::paint_icon(&painter, home, "home", f32::from(t.icon.a()) / 255.0);
// Cursors: a grabbing hand while orbiting, an open hand over the circle, a pointer on the home
// chip (the axis ends keep their pointing hand).
if hr.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} else if compass.orbiting {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if in_circle && hovered.is_none() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if hr.backing_on_hover_text(&compass.home_label).clicked() {
action = Some(ViewAction::Home);
}
action
}
/// Where a drag on a view navigator stands between frames: the pointer at the last step, and
/// where the cursor was warped back to (if it hit the screen's edge).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Drag {
pub at: Option<Pos2>,
pub warp: Option<Pos2>,
}
/// One frame of a drag on a view navigator: whether it started or ended, whether it is turning
/// the view, and this frame's step.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Orbit {
/// A press landed on the navigator and holds it.
pub grabbed: bool,
/// The button was let go of a held navigator.
pub released: bool,
/// The drag is turning the view.
pub orbiting: bool,
pub step: Option<OrbitStep>,
}
/// Track a drag on a view navigator. A press may start one (`may_start`: the pointer is over the
/// view and free to act) where `starts_at` says the navigator is; once the drag is decided each
/// frame's motion is an [`OrbitStep`]. The cube and the custom body share it.
pub fn orbit(ui: &egui::Ui, drag: &mut Drag, may_start: bool, starts_at: impl Fn(Pos2) -> bool) -> Orbit {
let mut out = Orbit::default();
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
let p = &i.pointer;
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
});
if pressed && may_start {
drag.at = origin.filter(|p| starts_at(*p));
drag.warp = None;
out.grabbed = drag.at.is_some();
}
if !down && drag.at.take().is_some() {
drag.warp = None;
out.released = true;
}
let Some(last) = drag.at.filter(|_| decided) else { return out };
out.orbiting = true;
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
if let Some(p) = pos {
// While a warp is pending the pointer may still sit at the edge it hit (the OS has not
// echoed the warp, or cannot), have landed at the centre (the echo), or have moved past
// it (the echo plus a fast flick): measure each step from whichever reference the
// pointer is nearer to, so the view never jumps by the warp distance and a platform
// that ignores warps keeps orbiting from the real pointer instead of freezing.
let from = match drag.warp {
Some(w) if p.distance(w) <= p.distance(last) => {
drag.warp = None;
w
}
_ => last,
};
out.step = Some(OrbitStep { dx: f64::from(p.x - from.x), dy: f64::from(p.y - from.y), snap: shift });
drag.at = Some(p);
// The OS stops feeding movement once the cursor is pinned at the screen edge, so an
// orbit toward that edge would stall: warp the cursor back to the window centre and
// measure the next steps from there, keeping the orbit tracking while the button is held (#9943).
let screen = ui.ctx().viewport_rect();
let edge = 6.0;
if drag.warp.is_none()
&& (p.x <= screen.min.x + edge || p.x >= screen.max.x - edge || p.y <= screen.min.y + edge || p.y >= screen.max.y - edge)
{
let home = screen.center();
drag.warp = Some(home);
ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(home));
}
ui.ctx().request_repaint();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, RawInput};
/// Looking at the front: X to the right, Z up, -Y toward the viewer.
const FRONT: ViewAxes = ViewAxes { right: [1.0, 0.0, 0.0], up: [0.0, 0.0, 1.0], back: [0.0, -1.0, 0.0] };
fn run(ctx: &egui::Context, events: Vec<Event>, hover: Option<Pos2>, drag: &mut Drag) -> (Option<ViewAction>, Orbit) {
let mut out = (None, Orbit::default());
crate::test_support::frame(
ctx,
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, focused: true, ..Default::default() },
|ui| {
let orbit = super::orbit(ui, drag, true, |p| p.distance(pos2(200.0, 200.0)) <= RADIUS);
let compass = Compass {
clip: ui.max_rect(),
centre: pos2(200.0, 200.0),
axes: FRONT,
hover,
orbiting: orbit.orbiting,
snap_hint: true,
home_label: "Home".into(),
};
out = (show(ui, &compass), orbit);
},
);
out
}
fn click(ctx: &egui::Context, at: Pos2) -> Option<ViewAction> {
let mut drag = Drag::default();
run(ctx, vec![Event::PointerMoved(at)], Some(at), &mut drag);
let pressed = run(ctx, crate::test_support::pointer(at, true), Some(at), &mut drag).0;
pressed.or(run(ctx, crate::test_support::pointer(at, false), Some(at), &mut drag).0)
}
#[test]
fn a_click_on_a_tip_or_home_returns_its_view_from_plain_axes() {
let ctx = crate::fonts::test_context();
let tips = tips(&FRONT, pos2(200.0, 200.0));
let x = tips.iter().find(|t| t.0 == "x").map(|t| t.2).unwrap();
assert_eq!(x, pos2(200.0 + (38.0 * 0.84) as f32, 200.0), "X points right");
assert_eq!(click(&ctx, x), Some(ViewAction::Snap { axis: "x", direction: [1.0, 0.0, 0.0] }));
let z = tips.iter().find(|t| t.0 == "z").map(|t| t.2).unwrap();
assert_eq!(click(&ctx, z), Some(ViewAction::Snap { axis: "z", direction: [0.0, 0.0, 1.0] }));
assert_eq!(click(&ctx, pos2(163.0, 163.0)), Some(ViewAction::Home));
assert_eq!(click(&ctx, pos2(20.0, 20.0)), None, "outside the compass");
}
#[test]
fn a_drag_on_the_compass_returns_orbit_steps_and_ends_on_release() {
let ctx = crate::fonts::test_context();
let mut drag = Drag::default();
let start = pos2(180.0, 215.0);
run(&ctx, vec![Event::PointerMoved(start)], Some(start), &mut drag);
let (_, pressed) = run(&ctx, crate::test_support::pointer(start, true), Some(start), &mut drag);
assert!(pressed.grabbed && pressed.step.is_none());
let mut steps = Vec::new();
for x in [190.0, 200.0, 230.0] {
let at = pos2(x, 205.0);
let (snap, orbit) = run(&ctx, vec![Event::PointerMoved(at)], Some(at), &mut drag);
assert_eq!(snap, None, "dragging over a tip does not snap");
steps.extend(orbit.step);
}
let travel = steps.iter().fold((0.0, 0.0, false), |(x, y, held), s| (x + s.dx, y + s.dy, held || s.snap));
assert!(!steps.is_empty());
assert_eq!(travel, (50.0, -10.0, false), "the steps add up to the pointer's travel once the drag is decided");
let end = pos2(230.0, 205.0);
let (_, released) = run(&ctx, crate::test_support::pointer(end, false), Some(end), &mut drag);
assert!(released.released && drag.at.is_none());
}
}
+49 -124
View File
@@ -7240,99 +7240,58 @@ pub(crate) fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
})
}
const AXIS_LENGTH: f64 = 38.0;
/// The artwork sits inside the pointer reach so the compass keeps breathing room in its circle.
const AXIS_ART_SCALE: f64 = 0.84;
use crate::icons::navigation::AXIS_RADIUS;
/// The camera's view axes, as the view compass takes them.
fn view_axes(cam: &Camera) -> crate::view_compass::ViewAxes {
let (right, up, back) = cam.basis();
crate::view_compass::ViewAxes { right: right.into(), up: up.into(), back: back.into() }
}
/// Six signed world axes, sorted back to front so overlapping tips have one owner.
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> {
let (r, u, b) = cam.basis();
let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)]
.into_iter()
.map(|(name, d)| {
(name, d, c + vec2((d.dot(r) * AXIS_LENGTH * AXIS_ART_SCALE) as f32, -(d.dot(u) * AXIS_LENGTH * AXIS_ART_SCALE) as f32), d.dot(b))
})
.collect();
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
tips
/// Six signed world axes, sorted back to front so overlapping tips have one owner (for checks).
#[cfg(test)]
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<crate::view_compass::Tip> {
crate::view_compass::tips(&view_axes(cam), c)
}
/// The six signed world axes as `cam` poses them, for the navigator figure in Preferences: each
/// one's screen direction (right and down) and depth along the camera's back.
pub(crate) fn navigator_axes(cam: &Camera) -> [crate::ui_kit::NavigatorAxis; 6] {
let (right, up, back) = cam.basis();
let axis = |name: &'static str, d: Vec3| crate::ui_kit::NavigatorAxis {
name,
direction: d.into(),
screen: vec2(d.dot(right) as f32, -d.dot(up) as f32),
depth: d.dot(back),
};
[axis("x", Vec3::X), axis("-x", -Vec3::X), axis("y", Vec3::Y), axis("-y", -Vec3::Y), axis("z", Vec3::Z), axis("-z", -Vec3::Z)]
let axes = view_axes(cam);
crate::view_compass::AXES.map(|(name, direction)| {
let ([x, y], depth) = axes.pose(direction);
crate::ui_kit::NavigatorAxis { name, direction, screen: vec2(x as f32, y as f32), depth }
})
}
fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option<usize> {
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
#[cfg(test)]
fn axis_hit(tips: &[crate::view_compass::Tip], p: Pos2) -> Option<usize> {
crate::view_compass::hit(tips, p)
}
/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press
/// Dragging anywhere on the view navigator orbits about the view centre. A stationary press
/// on a tip chooses its view. Floating windows own their input over the gizmo.
fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
let p = &i.pointer;
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
});
if pressed && ui.is_enabled() && ui.rect_contains_pointer(rect) && !pos.is_some_and(|p| crate::shell::covered(app, p)) {
let pos = ui.input(|i| i.pointer.interact_pos());
let may_start = ui.is_enabled() && ui.rect_contains_pointer(rect) && !pos.is_some_and(|p| crate::shell::covered(app, p));
let mut drag = crate::view_compass::Drag { at: app.viewport.axis_drag, warp: app.viewport.axis_warp };
let orbit = crate::view_compass::orbit(ui, &mut drag, may_start, |p| {
let shown = crate::view_gizmo::shown(app);
app.viewport.axis_drag = origin.filter(|p| match shown.style {
crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(*p),
crate::prefs::NavigatorStyle::Custom if shown.shape.is_some() => body_gizmo::drag_origin(app, rect, *p),
match shown.style {
crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(p),
crate::prefs::NavigatorStyle::Custom if shown.shape.is_some() => body_gizmo::drag_origin(app, rect, p),
crate::prefs::NavigatorStyle::Cube | crate::prefs::NavigatorStyle::Custom => {
navigation_cube::drag_origin(app, axis_center(app, rect), *p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE)
navigation_cube::drag_origin(app, axis_center(app, rect), p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE)
}
});
app.viewport.axis_warp = None;
if app.viewport.axis_drag.is_some() {
app.viewport.orbit_free = None;
}
}
if !down && app.viewport.axis_drag.take().is_some() {
app.viewport.axis_warp = None;
});
(app.viewport.axis_drag, app.viewport.axis_warp) = (drag.at, drag.warp);
if orbit.grabbed || orbit.released {
app.viewport.orbit_free = None;
}
let Some(last) = app.viewport.axis_drag.filter(|_| decided) else { return false };
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
if let Some(p) = pos {
if let Some(crate::view_compass::OrbitStep { dx, dy, snap }) = orbit.step {
app.cancel_view_animation();
// While a warp is pending the pointer may still sit at the edge it hit (the OS has not
// echoed the warp, or cannot), have landed at the centre (the echo), or have moved past
// it (the echo plus a fast flick): measure each step from whichever reference the
// pointer is nearer to, so the view never jumps by the warp distance and a platform
// that ignores warps keeps orbiting from the real pointer instead of freezing.
let from = match app.viewport.axis_warp {
Some(w) if p.distance(w) <= p.distance(last) => {
app.viewport.axis_warp = None;
w
}
_ => last,
};
orbit_drag(app, rect, f64::from(p.x - from.x), f64::from(p.y - from.y), None, shift, false);
app.viewport.axis_drag = Some(p);
// The OS stops feeding movement once the cursor is pinned at the screen edge, so an
// orbit toward that edge would stall: warp the cursor back to the window centre and
// measure the next steps from there, keeping the orbit tracking while the button is held (#9943).
let screen = ui.ctx().viewport_rect();
let edge = 6.0;
if app.viewport.axis_warp.is_none()
&& (p.x <= screen.min.x + edge || p.x >= screen.max.x - edge || p.y <= screen.min.y + edge || p.y >= screen.max.y - edge)
{
let home = screen.center();
app.viewport.axis_warp = Some(home);
ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(home));
}
ui.ctx().request_repaint();
orbit_drag(app, rect, dx, dy, None, snap, false);
}
true
orbit.orbiting
}
/// The whole navigator's box takes the right-click for the navigator's menu, before the view
@@ -7380,64 +7339,30 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
return;
}
crate::scenario::publish_rect("axis_view:outer", axis_rect(app, rect));
let t = Tokens::get();
let c = axis_center(app, rect);
let orbiting = axis_view_orbit(app, ui, rect);
let tips = axis_tips(&app.cam, c);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p));
let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) };
let painter = ui.painter_at(rect);
// Anywhere in the compass circle drags the view; the circle turns fully opaque under the pointer.
let in_circle = hover.is_some_and(|p| p.distance(c) <= 49.5);
let fill = if in_circle || orbiting { 1.0 } else { 0.35 };
painter.circle(c, 49.5, t.overlay.gamma_multiply(fill), Stroke::new(1.0, t.border.gamma_multiply(0.75)));
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
if i == 3 {
// The origin dot sits behind the three front axes and over the three back ones.
painter.circle(c, 3.6, t.text.gamma_multiply(0.9), Stroke::new(1.4, t.panel));
}
crate::icons::navigation::axis_tip(&painter, c, name, d, *at, *depth, hovered == Some(i), t);
crate::scenario::publish_handle(&format!("axis_view:{name}"), *at);
}
crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
if let Some(i) = hovered
&& let Some((name, d, _, _)) = tips.get(i)
{
crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.scope_id().with("axis_view_tip"), Sense::hover())
.backing_on_hover_text(format!(
"View from {}. Click again to reverse. Drag to orbit{}.",
name.to_uppercase(),
if app.preferences.free_orbit { "" } else { "; hold Shift to snap" }
));
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
if ui.input(|i| i.pointer.primary_clicked()) {
let frame = app.cam.to_frame(*d);
let dir = if app.cam.back().dot(*d) > 0.9999 { -*d } else { *d };
let compass = crate::view_compass::Compass {
clip: rect,
centre: axis_center(app, rect),
axes: view_axes(&app.cam),
hover: ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p)),
orbiting,
snap_hint: !app.preferences.free_orbit,
home_label: solidcraft_ui_text::tr_label("Home view").to_owned(),
};
// The compass returns a tip or Home; its drag came from `axis_view_orbit` above.
match crate::view_compass::show(ui, &compass) {
Some(crate::view_compass::ViewAction::Snap { direction, .. }) => {
let d = Vec3::from(direction);
let frame = app.cam.to_frame(d);
let dir = if app.cam.back().dot(d) > 0.9999 { -d } else { d };
let mut to = app.cam.looking_from(dir);
if frame.z.abs() > 0.5 {
to.yaw = nearest_quarter_turn(app.cam.yaw);
}
app.animate_turn(to);
}
}
let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0));
crate::scenario::publish_target("axis_view:home", home);
let hr = ui.interact(home, ui.scope_id().with("axis_view_home"), Sense::click());
// The home button sits in its own small round chip.
painter.circle(home.center(), 12.0, t.overlay.gamma_multiply(if hr.hovered() { 0.9 } else { 0.5 }), Stroke::new(1.0, t.border));
icons::paint_icon(&painter, home, "home", f32::from(t.icon.a()) / 255.0);
// Cursors: a grabbing hand while orbiting, an open hand over the circle, a pointer on the home
// chip (the axis ends keep their pointing hand).
if hr.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} else if orbiting {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if in_circle && hovered.is_none() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if hr.backing_on_hover_text(solidcraft_ui_text::tr_label("Home view")).clicked() {
app.animate_view("home");
Some(crate::view_compass::ViewAction::Home) => app.animate_view("home"),
None => {}
}
}
@@ -685,7 +685,7 @@ fn compass_negative_ends_are_hollow_and_positive_ends_are_chunkier() {
let original = c + vec2(38.0, 0.0);
let painted = c + (original - c) * 0.84;
let output = draw(&ctx, |ui| {
crate::icons::navigation::axis_tip(ui.painter(), c, name, &direction, painted, 0.5, false, t);
crate::icons::navigation::axis_tip(ui.painter(), c, name, direction.into(), painted, 0.5, false, t);
});
let circles: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some(c) } else { None }).collect();
let end = circles.iter().find(|circle| circle.center == painted).unwrap();
@@ -929,7 +929,7 @@ fn a_hovered_compass_end_turns_white_and_its_letter_takes_the_axis_colour() {
let original = c + vec2(38.0, 0.0);
let painted = c + (original - c) * 0.84;
let output = draw(&ctx, |ui| {
crate::icons::navigation::axis_tip(ui.painter(), c, name, &direction, painted, 0.5, true, t);
crate::icons::navigation::axis_tip(ui.painter(), c, name, direction.into(), painted, 0.5, true, t);
});
let circles: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some(c) } else { None }).collect();
assert!(circles.iter().any(|circle| circle.center == painted && circle.fill == t.on_accent), "{name}: the hovered end is filled white");
+9
View File
@@ -44,6 +44,15 @@ when you send a message or press **Connect** (or at launch, when you turned that
## The panel
With **Preferences → Layout → Use shared panel layout** enabled, side panels keep
the conversation above the composer. A wide floating panel places the conversation
beside the provider controls and composer when both columns have enough width.
Smaller widths keep the stacked layout. The conversation, unsent draft and scroll
state stay with the same Assistant when the layout changes. Hovering over its shared
header reports the current agent state or pending approvals; requests that need
attention use the shared attention indication. Classic and the Assistant's own
native window keep their existing layout.
In the floating layout, Assistant fills the viewport height in the shared rounded panel, with
space around its edges. The compass and navigation bar move into the remaining space beside it. It overlays the model without changing the viewport projection;
clicks and scrolling over the panel stay in the chat. The docked layout retains its side panel.
+39
View File
@@ -0,0 +1,39 @@
Cut a flat bevel along edges of a solid: the "break all edges" or "0.03 × 45°" callout on a print. You pick the edges and how the chamfer is sized.
#### Before you start
- You need a solid body with edges to chamfer.
- Pick edges one at a time, or click a face to chamfer every edge around it.
- Chamfer edges before you add other features that touch them. An edge that already meets a small chamfer or fillet has little room left, and a chamfer there can fail.
#### Options
| Choice in the dialog | Meaning | Example or reason to choose it |
| --- | --- | --- |
| Edges | The edges to chamfer. Click edges one by one, or click a face to take all of its edges. | The four top edges of a block, or the top face. |
| Chamfer Type | Equal Distance: the same leg on both faces. That is a 45° chamfer only where the faces meet at 90°. Two Distances: a different leg on each face. Distance and Angle: one leg and an angle. | Equal Distance for a standard edge break on a square corner. Distance and Angle whenever the print gives an angle, like 0.06 × 30°, and for any chamfer on a sloped face. |
| Distance (Equal Distance) | The leg length on both faces. | 0.03 in for a typical edge break. |
| Distance 1 and Distance 2 (Two Distances) | The leg on each face. | 0.06 in by 0.03 in. |
| Distance 1 and Angle (Distance and Angle) | The leg on the first face, and the angle off that face. | 0.06 in at 45°. |
| Flip | Swaps which face the first distance is measured on. Shown for Two Distances and Distance and Angle. | Turn it on if the long leg ends up on the wrong face. |
| Tangent chain | Follows the edge around smooth (tangent) bends, such as a fillet, so one pick chamfers the whole run. | Leave it on for edges that flow into a fillet. |
| Preview | Shows the chamfer on the part while you set it up. | Leave it on. |
#### Try it: break the top edges of a plate
1. Make a 3 in × 2 in × 0.75 in block (sketch a rectangle, Extrude 0.75 in).
2. Click Chamfer and click the four top edges. Edges shows 4 selected.
3. Set Chamfer Type to Equal Distance and Distance to 0.03 in.
4. Click OK. All four top edges have a 0.03 × 45° chamfer.
5. To change it, edit the chamfer and set Chamfer Type to Distance and Angle, Distance 1 to 0.06 in and Angle to 30. Use Flip to swap which face takes the 0.06 in leg.
#### Tips and common mistakes
- **Do** type the unit with the number when the print uses another unit. In a millimetre design, typing 0.03 in enters 0.762 mm.
- **Do** chamfer all the edges of one size in a single Chamfer. It's one feature to change when the print changes.
- **Do** pick the edges one by one for Two Distances or Distance and Angle. Picking a whole face works with Equal Distance, but the other two types can stop with "not supported yet: chamfers ending at a concave corner".
- **Don't** chamfer an edge right next to an existing small chamfer with a larger size. If the chamfer would run past the neighbouring face, the dialog says so; make the chamfer smaller or chamfer those edges together in one feature.
#### See also
{{#command solid.hole}} adds a countersink at the top of a hole. {{#command solid.fillet}} rounds an edge instead of beveling it.
+11 -6
View File
@@ -10,19 +10,22 @@ Put a hole in a solid body: a simple hole, a counterbore, a countersink or a tap
| Choice in the dialog | Meaning | Example or reason to choose it |
| --- | --- | --- |
| Personal Presets | Saves the dialog settings under a name you choose (Save new), so you can reuse them. Rename and Delete manage saved presets. | Save a "1/4-20 blind" preset once and pick it for every tapped hole of that size. |
| Personal Presets | Saves the dialog settings under a name you choose (Save new), so you can reuse them. Presets are saved in your profile, so they carry over to every design. Rename and Delete manage saved presets. | Save a "1/4-20 blind" preset once and pick it for every tapped hole of that size. |
| Placement | Single drills one hole on the face you pick. Multiple drills a hole at each sketch point you select. | Single for one hole located from edges. Multiple for a bolt pattern laid out in a sketch. |
| Face | The face the hole is drilled into. | Click the top face of the part. |
| Reference 1 and Distance 1 | An edge of the face, and how far the hole center is from it. | The left edge, 0.75 in. |
| Reference 2 and Distance 2 | A second edge, and how far the hole center is from it. | The front edge, 0.75 in. |
| Extents | How the depth is set. Distance drills to the Depth you type. | Use Distance for a blind hole. |
| Hole Type | Simple is a straight hole. Counterbore and Countersink add a bore or a cone at the top. | Counterbore for a socket head cap screw. Countersink for a flat head screw: 82° for inch screws, 90° for metric. |
| Hole Tap Type | Simple is a plain drilled hole with a Diameter you type. Tapped drills the tap drill size and carries the thread as a callout; the thread itself is not cut into the hole wall. | Choose Tapped for any tapped hole. To show real thread geometry, use {{#command solid.thread}} on the hole wall. |
| Hole Tap Type | Simple is a plain drilled hole with a Diameter you type. Tapped drills the tap drill size and shows the thread settings. | Choose Tapped for any tapped hole. |
| Profile (Tapped) | The thread standard. | UNC for coarse inch threads, UNF for fine inch threads. |
| Size (Tapped) | The thread size, from a list. The Designation box shows the callout, and Tap drill diameter shows the drill the app uses. | 1/4-20 UNC uses a 0.201 in (#7) tap drill. The app shows it as 5.1054 mm. |
| Size (Tapped) | The thread size, from a list. The Designation box shows the callout. | 1/4-20 UNC. |
| Tap drill diameter (Tapped) | The drill for the tapped hole. It fills in the standard tap drill for the size, the one on a tap drill chart (the app's messages call it "the tap maker's drill"), and you can type a different one. The app accepts drills from the smaller of the thread's minor diameter and the standard tap drill, up to 5% over the standard tap drill, and says the allowed range if you go outside it. For a custom metric size with no entry in the app's tap drill table, the app fills in major diameter minus pitch, the usual rule of thumb; check a tap drill chart. | 1/4-20 UNC fills in 5.1054 mm (0.201 in, a #7 drill). Typing 0.203 in (13/64) is accepted; 0.220 in is refused because too little thread would engage. |
| Full thread depth (Tapped) | On: the thread runs the full depth of the hole. Off: a Thread depth box appears. | Turn it off for a blind tapped hole. |
| Thread depth (Tapped) | How deep the full thread goes, separate from the drill Depth. | Drill 0.625 in deep with 0.50 in of thread, so a standard tap doesn't bottom out. |
| Thread direction (Tapped) | Right-hand or left-hand thread. | Right-hand unless the print calls for LH. |
| Hand (Tapped) | Right or Left. | Right unless the print calls for LH. |
| Modeled (Tapped) | On: the thread groove is cut into the hole wall. Off: the thread is carried as a callout only. | Leave it off unless you need to see the thread or 3D print it. A modeled thread makes the part slower to rebuild. |
| End (Modeled on) | Square stops the thread sharp at the bottom. Fade runs it out over a length, the way a tap's chamfered end leaves it. | Fade for a realistic blind tapped hole. |
| Drill Point | The bottom of the hole. Angle leaves a drill point. | Use Angle for a hole made with a twist drill. |
| Depth | How deep the hole goes. | Measured to the end of the full diameter, not the tip of the drill point. |
| Tip Angle | The angle of the drill point. Default is 118°. | 118° for a standard jobber drill. 135° for most carbide and split-point drills. |
@@ -36,11 +39,13 @@ Put a hole in a solid body: a simple hole, a counterbore, a countersink or a tap
4. Click Reference 1, pick the left edge of the top face and set Distance 1 to 0.75 in. Click Reference 2, pick the front edge and set Distance 2 to 0.75 in.
5. Set Hole Tap Type to Tapped, Profile to UNC and Size to 1/4-20 UNC. Check that Tap drill diameter shows 5.1054 mm (0.201 in).
6. Turn off Full thread depth. Set Depth to 0.625 in and Thread depth to 0.50 in. Click OK.
#### Tips and common mistakes
- **Do** locate holes from edges the way the print dimensions them. When the part changes size, the holes keep their distances from those edges.
- **Do** use Tapped and pick the size from the list instead of typing a drill diameter. The hole then carries the thread callout, and the tap drill comes from the tap maker's table.
- **Do** check the Tap drill diameter before clicking OK. It is shown in millimetres: 5.1054 mm is 0.201 in.
- **Do** use Tapped and pick the size from the list instead of typing a drill diameter. The hole then carries the thread callout, and the tap drill comes from a standard tap drill chart.
- **Do** check the Tap drill diameter before clicking OK. It is shown in millimetres: 5.1054 mm is 0.201 in. If your shop runs a different drill, such as 0.203 in for 1/4-20 in steel, type it in.
- **Don't** turn on Modeled just to show the hole is tapped. The callout already says it.
- **Do** turn off Full thread depth on blind tapped holes and set a Thread depth shorter than the drill Depth, so a standard tap doesn't bottom out.
- **Do** type the unit with the number when the print is in another unit. In a millimetre design, typing 0.75 in enters 19.05 mm.
- **Don't** expect every hole to work on sheet metal. Sheet metal holes must go straight into a flat panel; holes into the edge of the sheet, countersinks and drill points are refused there.
+50
View File
@@ -0,0 +1,50 @@
Put a thread on a round face: an external thread on a shaft or stud, or an internal thread in a bore. You pick the thread form and size, how far the thread runs, and how its ends are finished.
#### Before you start
- You need a body with a cylindrical face: the outside of a shaft for an external thread, or the wall of a bore for an internal one.
- Size the cylinder first. For an external thread, make the shaft the thread's major diameter (12 mm for M12). For an internal thread, make the bore the tap drill size.
- Click the face near the end you want threaded. On a shaft with two free ends, that tells a limited-length thread where to start.
- For an ordinary tapped hole, including UNC and UNF, use {{#command solid.hole}} with Hole Tap Type set to Tapped instead. It drills the tap drill and keeps the thread callout on the hole.
#### Options
| Choice in the dialog | Meaning | Example or reason to choose it |
| --- | --- | --- |
| Cylindrical face | The round face the thread goes on. | Click the outside of the shaft near the end to thread. |
| Form | The thread standard: ISO metric, Trapezoidal (ISO 2904), Acme (ASME B1.5), Buttress (DIN 513 or ASME B1.9), and the pipe threads Pipe G (ISO 228-1), Pipe Rp (ISO 7-1), Pipe taper R, Rc (ISO 7-1) and Pipe taper NPT (ASME B1.20.1). UNC and UNF are not in this list. | ISO metric for fasteners. Acme or Trapezoidal for lead screws. Buttress for a screw that takes load one way, like a vise screw. Pipe taper NPT for US tapered pipe threads. |
| Modeled | On: the thread groove is cut into the part. Off: the thread is carried as a callout only. | Leave it off unless you need to see the thread or 3D print it. A modeled thread makes the part slower to rebuild. |
| Automatic size | On by default: picks the standard size that fits the face you selected. | Leave it on when the shaft is a standard diameter. |
| Size and Pitch | The thread size from the standard's table, and for metric threads the pitch. | M12 with the standard 1.75 mm pitch, or 1.25 mm for M12 fine. |
| Designation | The thread callout. You can type it directly. | M12, M12x1.25. |
| Hand | Right, Left, or Both. Pipe threads offer Right or Left only. | Right unless the print calls for LH. |
| Starts | Number of thread starts, 1 to 8. | 1 for fasteners. 2 or more for a fast-moving lead screw: each turn advances the pitch times the number of starts. |
| Steep Flank (Buttress only) | Which end the steep, load-carrying flank faces. | Point it toward the load. |
| Extent | Full runs the whole face. Length runs a set distance. Up to stops at a face or plane you pick. | Length for a threaded end on a longer shaft. Up to for a thread that stops at a shoulder. |
| Start end (optional) | Which end of the face the thread is measured from. Shown once Extent is Length or Up to. | Pick it when the thread starts at the wrong end. |
| Start (Ends) | None leaves the thread's free end plain. Chamfer adds a lead-in chamfer and shows Chamfer Angle and Chamfer Depth; left blank, they use the ISO 4753 standard chamfer. | Chamfer the start of any thread that has to take a nut or go into a tapped hole by hand. |
| End (Ends) | Square stops the thread sharp. Fade runs it out over a length (ISO 3508 runout, 2.5 pitches standard). | Fade for a thread cut with a die or single-point tool that stops short of a shoulder. |
#### Try it: a threaded shaft end
1. Sketch a 12 mm circle on the XY plane and extrude it 50 mm.
2. Click Thread, then click the round face of the shaft near its top end.
3. Check that Form is ISO metric and Automatic size picked M12 with a 1.75 mm pitch.
4. Set Extent to Length and the length to 20 mm.
5. Turn on Modeled to see the thread cut into the shaft.
6. Under Ends, set Start to Chamfer. Leave Chamfer Angle and Chamfer Depth blank for the standard ISO 4753 chamfer.
7. Click OK. The top 20 mm of the shaft carries an M12 thread with a lead-in chamfer.
#### Tips and common mistakes
- **Do** size the cylinder before threading it: major diameter for an external thread, tap drill size for an internal one.
- **Do** click the face near the end you want threaded, or pick Start end. Otherwise a Length thread can start at the wrong end.
- **Do** chamfer the start of an external thread. The shop will put one there anyway, and a nut starts on it in the model the same way it does on the part.
- **Do** leave room for runout before a shoulder. A thread can't run full depth right up to a shoulder without a relief groove.
- **Don't** turn on Modeled just to show the part is threaded. The callout already says it.
- **Don't** look for UNC or UNF here. Inch fastener threads are only on {{#command solid.hole}} tapped holes for now; the inch forms in this list are Acme, ASME buttress and the pipe threads.
- **Don't** mix up NPT and the ISO taper (R, Rc, often called BSPT). Both are tapered pipe threads, but the thread angle and pitch differ, and they don't seal together.
#### See also
{{#command solid.hole}} makes tapped holes with the right tap drill, including UNC and UNF. {{#command solid.chamfer}} breaks an edge by hand. {{#command solid.revolve}} makes a turned part to thread.
+1 -1
View File
@@ -85,7 +85,7 @@ origin; the design itself does not move. The face is tinted and outlined in the
copy made with Move/Copy or Mirror keeps it. A print face that is gone or no longer flat is
reported, and the body is sent as it lies. Curved faces and mesh bodies can't be print faces.
Known limitation: after a shell of a body that is already shelled, no face of that body can be
marked, as no sketch can be attached to one either; this needs kernel work, which is paused.
marked, as no sketch can be attached to one either; this needs kernel work in solidcore.
The list also has a plain **EasyPrint (Printables, in the browser)** row, and it is the one entry
that works in the browser build too. EasyPrint is Printables' slicer, so choosing it uploads the
+9 -1
View File
@@ -26,6 +26,14 @@ Browser/History uses the existing content preference and suppresses the separate
History body. Render Inspector, Shots and appearances keep their existing hosts
and contribute their actual footprints to navigation and camera fitting.
Assistant uses the shared flow and actual content width. Tall content keeps the
conversation above its pinned composer. A viable Wide presentation puts the
conversation beside the existing provider controls and composer; smaller widths
retain the stacked presentation. Both use the same content identities, draft,
scroll state and backend. Shared header status on hover and its attention indication
come from the existing Assistant state and pending approvals. Classic and native
Assistant composition is unchanged.
Visible bodies report their natural scroll content and fixed control bands.
A sole production panel hugs that content until column capacity requires scrolling;
Assistant declares Fill to use the full available side height. A newly visible unmeasured
@@ -50,7 +58,7 @@ Each section contains ordered tabs. Inactive tabs still constrain composite size
limits. Registrations declare allowed zones, PinOuter, Content/Fill height policy,
text-heavy content, surface preference, header actions and independent
`closable`/`hot_closable` flags. No generic host branches on product panel names.
Only future History product registration allows Bottom; synthetic kinds can allow
Only the History product registration allows Bottom; synthetic kinds can allow
all zones. Quick close is an affordance, not the close capability itself.
Layout is application/workspace preference data. It never enters CAD files,
+1 -1
View File
@@ -413,7 +413,7 @@ completed drawings publish atomically. The browser still exports synchronously.
- Print faces (#8429) are held like sketches on faces, so the same limit applies: after a shell
of a body that is already shelled, no face of that body has an exact name, and none can be
marked (`body.print_face` says so). Fixing it needs the shell to report its face history,
which is kernel work, paused for now (CONTRIBUTING.md, "Kernel work is paused").
which is kernel work, done in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
-91
View File
@@ -1,91 +0,0 @@
'use strict';
// While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
// pull requests that change the kernel directories, get the `kernel-paused` label, so a maintainer
// finds them and answers in person (CONTRIBUTING.md, "Kernel work is paused"). Reports of crashes
// and data loss are left alone, and so is anyone who can push to the repository.
const LABEL = 'kernel-paused';
const LABEL_COLOR = 'b60205';
const LABEL_DESCRIPTION = 'Kernel work is paused while a new geometry kernel is evaluated';
// Kernel operations named in the title of an issue or discussion. "Hole" and "shell" are left out
// on purpose: they name UI work too. A pull request is judged by its files, never by its title.
const TOPIC =
/\b(booleans?|fillets?|chamfers?|offsets?|sweeps?|lofts?|threads?|step|stp|iges|igs|tessellat\w*|kernel|truck|b-?rep)\b/i;
// Crashes and lost work come first whatever the kernel is.
const URGENT = /\b(crash\w*|panic\w*|data loss|lost (my )?work|corrupt\w*)\b/i;
// What a pull request changes that makes it kernel work.
const KERNEL_PATHS = ['crates/kernel/', 'crates/geom/', 'solidcore/'];
function isTopic(title) {
return TOPIC.test(title || '');
}
function isUrgent(title, body) {
return URGENT.test(`${title || ''}\n${body || ''}`);
}
function touchesKernel(files) {
return files.some((f) => KERNEL_PATHS.some((p) => f.startsWith(p)));
}
// Whether the author can push to the repository (maintainers and their agents are not paused).
async function canPush({ github, context }, username) {
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...context.repo, username });
return ['admin', 'maintain', 'write'].includes(data.permission);
} catch {
return false;
}
}
async function ensureLabel({ github, context }) {
try {
await github.rest.issues.getLabel({ ...context.repo, name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({ ...context.repo, name: LABEL, color: LABEL_COLOR, description: LABEL_DESCRIPTION });
}
}
// A new issue or pull request: returns what was done, for the log.
async function handleOpened({ github, context }) {
const item = context.payload.pull_request || context.payload.issue;
if (!item) return 'nothing to do';
const pull = Boolean(context.payload.pull_request);
if (isUrgent(item.title, item.body)) return `#${item.number}: a crash or data-loss report, left alone`;
if (await canPush({ github, context }, item.user.login)) return `#${item.number}: by a maintainer, left alone`;
let kernel;
if (pull) {
const files = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: item.number, per_page: 100 });
kernel = touchesKernel(files.map((f) => f.filename));
} else {
kernel = isTopic(item.title);
}
if (!kernel) return `#${item.number}: not kernel work`;
await ensureLabel({ github, context });
await github.rest.issues.addLabels({ ...context.repo, issue_number: item.number, labels: [LABEL] });
return `#${item.number}: labelled ${LABEL}`;
}
// A new discussion.
async function handleDiscussion({ github, context }) {
const d = context.payload.discussion;
if (!d) return 'nothing to do';
if (!isTopic(d.title) || isUrgent(d.title, d.body)) return `discussion #${d.number}: left alone`;
if (await canPush({ github, context }, d.user.login)) return `discussion #${d.number}: by a maintainer, left alone`;
await ensureLabel({ github, context });
const { repository } = await github.graphql(
'query($owner: String!, $repo: String!, $name: String!) { repository(owner: $owner, name: $repo) { label(name: $name) { id } } }',
{ ...context.repo, name: LABEL },
);
if (repository && repository.label) {
await github.graphql('mutation($id: ID!, $labels: [ID!]!) { addLabelsToLabelable(input: {labelableId: $id, labelIds: $labels}) { clientMutationId } }', {
id: d.node_id,
labels: [repository.label.id],
});
}
return `discussion #${d.number}: labelled ${LABEL}`;
}
module.exports = { LABEL, isTopic, isUrgent, touchesKernel, handleOpened, handleDiscussion };
-109
View File
@@ -1,109 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const paused = require('./kernel-paused.js');
const repo = { owner: 'tangency-labs', repo: 'solidcraft' };
// A fake github-script client that records writes. `world` describes the repo.
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
graphql: async (query, args) => {
calls.push([query.trim().split(/[\s({]/)[0] === 'query' ? 'query' : query.match(/\{\s*(\w+)/)[1], args]);
return { repository: { label: { id: 'L1' } } };
},
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => ({ data: { permission: (world.perms || {})[username] || 'read' } }),
},
pulls: { listFiles: async () => ({ data: (world.files || []).map((filename) => ({ filename })) }) },
issues: {
getLabel: async () => {
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
return { data: {} };
},
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
createComment: rec('comment'),
},
},
};
return { github, calls };
}
const names = (calls) => calls.map((c) => c[0]);
test('kernel topics are named in titles; hole and shell are not', () => {
for (const t of ['Fillet fails on a cone', 'Booleans: cut leaves a sliver', 'STEP import drops a face', 'Tessellation hangs']) {
assert.ok(paused.isTopic(t), t);
}
for (const t of ['Hole dialog: diameter field', 'Shell command icon', 'Sketch: dimension text', 'Stepper widget']) {
assert.ok(!paused.isTopic(t), t);
}
});
test('a kernel issue is labelled, nothing more', async () => {
const { github, calls } = fake({ labelMissing: true });
const context = { repo, payload: { issue: { number: 5, title: 'Chamfer on a spline edge fails', body: '', user: { login: 'someone' } } } };
await paused.handleOpened({ github, context });
assert.deepEqual(names(calls), ['createLabel', 'addLabels']);
});
test('crashes, data loss and maintainers are left alone', async () => {
for (const body of ['The app crashed when I filleted this', 'I lost my work after a boolean']) {
const { github, calls } = fake();
await paused.handleOpened({ github, context: { repo, payload: { issue: { number: 6, title: 'Fillet', body, user: { login: 'someone' } } } } });
assert.deepEqual(calls, [], body);
}
const { github, calls } = fake({ perms: { owner: 'admin' } });
await paused.handleOpened({ github, context: { repo, payload: { issue: { number: 7, title: 'Fillet', body: '', user: { login: 'owner' } } } } });
assert.deepEqual(calls, []);
});
test('a pull request that changes the kernel is labelled, whatever its title', async () => {
const { github, calls } = fake({ files: ['docs/a.md', 'crates/kernel/src/blend.rs'] });
const pr = { number: 8, title: 'Speed up a hot path', body: '', draft: false, node_id: 'PR_1', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(names(calls), ['addLabels']);
});
test('a pull request outside the kernel is left alone', async () => {
const { github, calls } = fake({ files: ['crates/ui-egui/src/dialogs.rs'] });
const pr = { number: 9, title: 'Dialog spacing', body: '', draft: false, node_id: 'PR_2', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(calls, []);
});
test('a pull request with a kernel topic in its title but no kernel files is left alone', async () => {
const { github, calls } = fake({ files: ['crates/ui-egui/src/dialogs.rs'] });
const pr = { number: 10, title: 'Fillet dialog: a clearer radius field', body: '', draft: false, node_id: 'PR_3', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(calls, []);
});
test('a pull request that changes crates/geom is labelled', async () => {
const { github, calls } = fake({ files: ['crates/geom/src/lib.rs'] });
const pr = { number: 11, title: 'Tidy a helper', body: '', draft: false, node_id: 'PR_4', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(names(calls), ['addLabels']);
});
test('a pull request that changes the vendored kernel is labelled', async () => {
const { github, calls } = fake({ files: ['solidcore/crates/solidcore/src/blend.rs'] });
const pr = { number: 12, title: 'Tidy a helper', body: '', draft: false, node_id: 'PR_5', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(names(calls), ['addLabels']);
});
test('a kernel discussion is labelled', async () => {
const { github, calls } = fake();
const d = { number: 3, title: 'Ideas for better booleans', body: '', node_id: 'D_1', user: { login: 'someone' } };
await paused.handleDiscussion({ github, context: { repo, payload: { discussion: d } } });
assert.deepEqual(names(calls), ['query', 'addLabelsToLabelable']);
});
+9 -1
View File
@@ -54,12 +54,17 @@ function parseDefaults(body) {
if (pref && pref[1].trim() && !/^<.*>$/.test(pref[2])) {
return { status: 'yes', detail: pref[1].trim(), preference: pref[2] };
}
// solidcore's rule is the opposite of the app's: a kernel result change becomes the default and the
// old path is deleted, no flag (solidcore/AGENTS.md §4b). Accepted only for PRs that stay in solidcore/.
const kernel = yes[1].match(/^(.*?)[ ,;]*\bkernel result change\b[ ,;]*no (?:preference|flag)\.?$/i);
if (kernel && kernel[1].trim()) return { status: 'kernel', detail: kernel[1].trim() };
}
return { status: 'malformed', detail: value };
}
const DEFAULTS_HELP =
'Add one line to the description: `Changes a default: no`, or ' +
'(solidcore/ only) `Changes a default: yes — <what changes>; kernel result change, no preference`, or ' +
'`Changes a default: yes — <what>, old behaviour kept behind preference <name>`.';
function isBot(user) {
@@ -151,7 +156,10 @@ async function checkDefaults({ github, context, core }) {
})).map((f) => f.filename);
if (isDocsOnly(files)) return { skipped: 'docs only' };
const result = parseDefaults(pull.body);
if (result.status === 'missing') {
if (result.status === 'kernel' && !files.every((f) => f.startsWith('solidcore/') || DOC_FILE.test(f))) {
core.setFailed('"kernel result change, no preference" is only for pull requests that change solidcore/ alone; ' +
`a change to the app keeps its old behaviour behind a preference. ${DEFAULTS_HELP}`);
} else if (result.status === 'missing') {
core.setFailed(`The description has no "Changes a default:" line. ${DEFAULTS_HELP}`);
} else if (result.status === 'malformed') {
core.setFailed(`Could not read "Changes a default: ${result.detail}". ${DEFAULTS_HELP}`);
+7
View File
@@ -47,6 +47,13 @@ test('parseDefaults yes forms', () => {
assert.equal(pc.parseDefaults('Changes a default: yes — x, old behaviour kept behind preference <name>').status, 'malformed');
});
test('parseDefaults kernel result change (solidcore/ only)', () => {
const r = pc.parseDefaults('Changes a default: yes — the fillet corner patch is G2 now; kernel result change, no preference');
assert.equal(r.status, 'kernel');
assert.equal(r.detail, 'the fillet corner patch is G2 now');
assert.equal(pc.parseDefaults('Changes a default: yes - kernel result change, no preference').status, 'malformed');
});
test('isBot and isDocsOnly', () => {
assert.ok(pc.isBot({ login: 'dependabot[bot]', type: 'Bot' }));
assert.ok(pc.isBot({ login: 'solidcraft-generated[bot]' }));
+9 -5
View File
@@ -72,7 +72,7 @@ not modelling a part through MCP.
operation protocol in `plan/execution-plan.md` section 6; do not stop to ask unless it lists the decision
as the owner's. Contributors without `plan/` can ignore this.
- **Update the roadmap** when a milestone task lands: `docs/roadmap-detail.md` tracks status, milestones and estimates; `ROADMAP.md` is the short user-facing roadmap.
- **Start here.** What to work on: `ROADMAP.md`, "Where help is wanted most" in CONTRIBUTING.md and the open issues labelled `help wanted`, `good first issue`, `needs-triage`; check what is already open first. Architecture: `docs/architecture.md` and "How the code is laid out" in CONTRIBUTING.md; kernel work is paused ("Kernel work is paused" in CONTRIBUTING.md). Licence rules: `deny.toml` and `cargo xtask licences --check`; only `crates/smlib` may use `solidcore`. When the code, docs and rules settle a question, act; ask on the issue or PR only when the answer changes what users see or what the change covers.
- **Start here.** What to work on: `ROADMAP.md`, "Where help is wanted most" in CONTRIBUTING.md and the open issues labelled `help wanted`, `good first issue`, `needs-triage`; check what is already open first. Architecture: `docs/architecture.md` and "How the code is laid out" in CONTRIBUTING.md; kernel work goes in `solidcore/` ("Kernel work goes in solidcore/" in CONTRIBUTING.md). Licence rules: `deny.toml` and `cargo xtask licences --check`; only `crates/smlib` may use `solidcore`. When the code, docs and rules settle a question, act; ask on the issue or PR only when the answer changes what users see or what the change covers.
- **Do not open a second PR** for an issue a lower-numbered open PR already closes, and keep the `Changes a default:` line filled in: `pr-checks.yml` fails both.
- **The gate reads the working tree.** Do not switch branches while `cargo xtask ci` runs: it compiles the other branch's code and fails on a change not in the PR. Run it in the background and wait for its log.
@@ -95,6 +95,11 @@ not modelling a part through MCP.
- **Do not parallelize what depends on itself.** A fix and the test that proves it, a
refactor and the callers it breaks, two edits to the same file: those stay sequential.
One subagent per independent unit of work, and no two units may write the same file.
- **Keep the cost down.** Keep one long-running agent session instead of restarting it.
Don't `--resume` a session after its prompt cache has gone cold: a cold resume re-reads the
whole history at full price. Act on CodeRabbit comments only when they're about crashes,
wrong results, data loss or security, and ignore style nitpicks. Don't push just to
re-trigger a review.
## 1. Check the environment
@@ -153,15 +158,14 @@ fails, inspect the divergence and preserve existing work; do not reset it away.
## 3. Choose an unclaimed task together
Ask about their interests and show a few issues that fit the available resources.
Read the current kernel-pause notice in CONTRIBUTING.md and skip kernel work
while paused, including tasks outside `crates/kernel/` that change kernel behaviour.
Kernel and geometry tasks are fine: they go in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
Fetch the whole open list, including drafts, before narrowing it:
```sh
gh issue list -R tangency-labs/solidcraft --state open --limit 500
gh pr list -R tangency-labs/solidcraft --state open --limit 500 --json number,title,files
gh issue list -R tangency-labs/solidcraft --state open --label 'good first issue' --search '-label:kernel-paused -label:"in progress"' --limit 100
gh issue list -R tangency-labs/solidcraft --state open --label 'help wanted' --search '-label:kernel-paused -label:"in progress"' --limit 100
gh issue list -R tangency-labs/solidcraft --state open --label 'good first issue' --search '-label:"in progress"' --limit 100
gh issue list -R tangency-labs/solidcraft --state open --label 'help wanted' --search '-label:"in progress"' --limit 100
```
Let the person choose. Set `N` to that issue's number, then inspect its discussion,
+1 -1
View File
@@ -27,4 +27,4 @@ The desktop app writes every panic and every unclean exit to a local crash folde
- `solidcraft-cli crashes`, `solidcraft-cli crashes show <id>`, `solidcraft-cli crashes replay <id>`
- the `diagnostics.crash_reports` command, or the MCP `crash_reports` tool
Crashes and data loss are taken as issues even while kernel work is paused (CONTRIBUTING.md, "Kernel work is paused").
Crashes and data loss are taken first, including in kernel code, which lives in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
+1 -1
View File
@@ -21,7 +21,7 @@ Drawings follow the same rules with their own numbers, from 1: a drawing-format
- Imported files are untrusted: sizes, entity counts and nesting are capped, and every kernel call is guarded (`skills/solidcraft-crash-safety/SKILL.md`). What cannot be read becomes a warning on the feature.
- STEP is read by our own reader in `crates/kernel/src/step_in`, written from the public standards, with no third-party STEP code. The reader and what it supports: `docs/step-import.md`.
- Imported assemblies are rigid by default; Unlock Contents releases them.
- Kernel work (booleans, STEP/IGES import and export, tessellation) is paused except crashes and data loss: CONTRIBUTING.md, "Kernel work is paused".
- Kernel work (booleans, STEP/IGES import and export, tessellation) goes in `solidcore/`: CONTRIBUTING.md, "Kernel work goes in solidcore/".
## Export
- STEP, 3MF, STL, OBJ and URDF go through `file.export` and `file.save_mesh`; the MCP `export` tool calls them. URDF: `docs/urdf.md`. 3MF face tags: `docs/formats/3mf-face-tags.md`.
+1 -1
View File
@@ -23,5 +23,5 @@ The authority is `solidcore/docs/layers.md`; this is its short form. Every job l
## Working in solidcore/
- Follow `solidcore/AGENTS.md` and the reference doc for the area (`solidcore/docs/`). A change there needs a code owner's review (`.github/CODEOWNERS`) and runs SolidCore checks (its tests, goldens, kernel rig, clippy, fmt, panic sites) in `.github/workflows/ci.yml`.
- New kernel cases go in `solidcore/crates/solidcore/tests/kernel_cases/`, never in SolidCraft (`cargo xtask lists`).
- The kernel pause in CONTRIBUTING.md ("Kernel work is paused") still applies to `solidcore/`.
- Kernel work goes here: CONTRIBUTING.md, "Kernel work goes in solidcore/". Kernel-only PRs target the `kernel` branch.
- Issues and claims for solidcore are in this repository, like any other change (`skills/solidcraft-contributing/SKILL.md`): the vendored tree is mirrored to tangency-labs/solidcore by `.github/workflows/solidcore-mirror.yml`, so pull requests are not opened there.
+1 -1
View File
@@ -15,7 +15,7 @@ description: "Use when a SolidCraft change is slow, blocks the window, runs long
- Profile first. Time with `solidcraft-cli` from `main` and from your branch, alternating runs, after the load is low. On a shared machine check `cut -d' ' -f1 /proc/loadavg` and wait while it is over 24; build with `nice`.
- Report medians with the range, the machine, and the cases. The pattern to follow is `docs/perf.md`: what was slow, why, the result, and proof that nothing changed (same bodies, volumes, areas and triangle counts).
- A speed-up never skips work on a mesh's box: decide with the exact bounds (`Body::exact_bounds`, grown by the record's tolerance as `apart` in `crates/kernel/src/ops.rs` does). A mesh is for display, export, previews and mesh bodies.
- Do not rewrite kernel code for speed: the kernel is being replaced, and kernel work is paused. Report a slow case with its file under the `kernel-perf` label (CONTRIBUTING.md, "Where help is wanted most").
- A speed-up of kernel code goes in `solidcore/` with its root cause, per `solidcore/AGENTS.md`; do not grow `crates/kernel`. Report a slow case with its file under the `kernel-perf` label (CONTRIBUTING.md, "Where help is wanted most").
- Pull requests run only the tests for what they touch; geometry-heavy tests run in the merge queue. Use `cargo xtask test` with the `ci` profile locally (`skills/solidcraft-tests/SKILL.md`).
## The browser build