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
192 changed files with 4039 additions and 23655 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
-14
View File
@@ -230,20 +230,6 @@ jobs:
- run: rustup target add wasm32-unknown-unknown
- run: cargo check -p solidcore --target wasm32-unknown-unknown
# Each package builds on the Rust release its `rust-version` declares (scripts/ci/
# check-rust-version.sh). Downstream users build on older pinned toolchains than
# rust-toolchain.toml's, and nothing else here runs one.
rust-version:
needs: changes
if: needs.changes.outputs.full == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> bin"
- run: scripts/ci/check-rust-version.sh
# Same packaging check the release workflow runs before publishing (no secrets).
publish-dry-run:
needs: changes
-2
View File
@@ -1,5 +1,3 @@
> **solidcore is developed inside [SolidCraft](https://github.com/tangency-labs/solidcraft), at [`solidcore/`](https://github.com/tangency-labs/solidcraft/tree/main/solidcore).** The [tangency-labs/solidcore](https://github.com/tangency-labs/solidcore) repository is an automatic, read-only mirror of that folder. Open issues and pull requests in [tangency-labs/solidcraft](https://github.com/tangency-labs/solidcraft): kernel-only work against the `kernel` branch.
<p align="center"><img src="assets/logo.svg" width="140" alt="solidcore logo: an isometric solid with one filleted edge and a NURBS curve on its top face"></p>
# solidcore
@@ -1,8 +1,6 @@
{"format":1,"unit":"blend.feature_executive"}
{"case":"add_by_faces_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2774,380658],[2585,341911],[6017,756869],[2124,294143],[7047,692538],[7860,1181034],[13215,1649521],[2767,78418]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176984],[2441,349823],[4137,677755],[748,14692]],"n":804},"feature2_graph":{"blocks":[[1305,197906],[1748,275169],[1932,107176]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115412070844,687.1779118214433,52744.46080298995]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20154954.18077134],[116389.18300344986,116429.18300344986,12866754.328732701],[106102.57307303276,106122.57307303276,13684737.362150839],[45934.73239171605,45934.73239171605,3587621.6467926963]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6389177.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155065.5,155109.5,18928751.0],[105021.5,105109.5,12645808.0],[135.0,135.0,1804.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15852924.877656039],[60998.52675559926,60998.52675559926,4015371.2724186946]],"n":423},"record":{"blocks":[[326.0,346.0,12744.0]],"n":124}}}
{"case":"all_faces_defillet","deferred":null,"error":null,"ints":{"base_graph":[1,0,0,0,0,0,0,0,0,0,1],"feature0_graph":{"blocks":[[1743,230242],[2881,397902],[3189,537461],[5001,812093],[4768,323418]],"n":1165}},"topology":null,"values":{"base_geometry":[],"base_record":[0.0,0.0],"feature0_geometry":{"blocks":[[1.3599999999999958e+22,1.3599999999999958e+22,9.316000000000001e+23],[14.000069999999997,14.000069999999997,140.00077]],"n":276},"feature0_record":{"blocks":[[154466.63959673684,154466.63959673684,18841428.541271232],[129333.9587499411,129333.9587499411,14745007.961884614],[90266.21715728752,90266.21715728752,11236079.691673888],[9.0,9.0,68.0]],"n":779},"record":[1.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,9.0,1.0,0.0]}}
{"case":"all_faces_filleted_box","deferred":null,"error":null,"ints":{"base_graph":[1,0,0,0,0,0,0,0,0,0,1],"feature0_graph":{"blocks":[[1903,220645],[3189,516856],[3449,269618],[5567,663498],[7339,1115480],[11638,1409058]],"n":1528}},"topology":null,"values":{"base_geometry":[],"base_record":[0.0,0.0],"feature0_geometry":{"blocks":[[1.91999999999999e+22,1.91999999999999e+22,1.8528000000000002e+24],[452.0003399999995,452.0003399999995,26406.02375]],"n":378},"feature0_record":{"blocks":[[155397.5,155397.5,18957409.781469982],[154927.4900204164,154927.4900204164,20151093.307439756],[103966.76733889646,103996.76733889646,12124882.750080192],[75978.24264068715,76068.24264068715,7458816.37737983]],"n":988},"record":[1.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,10.0,1.0,0.0]}}
{"case":"attributes_add_by_faces_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2774,380658],[2585,341911],[6017,756869],[2124,294143],[7047,692538],[7860,1181034],[13215,1649521],[2767,78418]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176984],[2441,349823],[4137,677755],[748,14692]],"n":804},"feature2_graph":{"blocks":[[1305,197906],[1748,275169],[1932,107176]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115412070844,687.1779118214433,52744.46080298995]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20154954.18077134],[116389.18300344986,116429.18300344986,12866754.328732701],[106102.57307303276,106122.57307303276,13684737.362150839],[45934.73239171605,45934.73239171605,3587621.6467926963]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6389177.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155065.5,155109.5,18928751.0],[105021.5,105109.5,12645808.0],[135.0,135.0,1804.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15852924.877656039],[60998.52675559926,60998.52675559926,4015371.2724186946]],"n":423},"record":{"blocks":[[337.0,357.0,14391.0]],"n":164}}}
{"case":"attributes_inner_loop_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2548,339314],[2808,388653],[5366,597446],[3316,572452],[5984,574809],[9450,1382528],[11491,1234945]],"n":1760},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261}},"topology":null,"values":{"base_geometry":{"blocks":[[2.0799999999999883e+22,2.0799999999999883e+22,2.1736000000000002e+24],[363.8030081955735,560.3693788099324,25313.152184450642]],"n":418},"base_record":{"blocks":[[155416.25547015207,155492.8218407664,18950600.3615193],[155118.1152126431,155118.1152126431,20152219.349649835],[116983.14328273362,117023.14328273362,13745846.636862068],[91170.82416150477,91234.82416150477,11435584.550245512],[30337.0,30353.0,1019935.0]],"n":1118},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"record":{"blocks":[[81.0,89.0,1463.0]],"n":58}}}
{"case":"attributes_rebuild_fillets_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1906,252991],[3366,516788],[2314,400069],[4443,571496],[8162,998322]],"n":1264},"feature0_graph":{"blocks":[[853,122476],[847,63880]],"n":377}},"topology":null,"values":{"base_geometry":{"blocks":[[1.439999999999995e+22,1.439999999999995e+22,1.044e+24],[21.200139999999998,21.200139999999998,379.20287]],"n":296},"base_record":{"blocks":[[154399.14159265358,154399.14159265358,18832865.4645943],[128406.54159265356,128406.54159265356,14075364.427283542],[105272.0,105272.0,13472688.57781746],[15078.741592653581,15082.741592653596,542971.1221134894]],"n":844},"feature0_geometry":{"blocks":[[4e+21,4e+21,8.199999999999998e+22]],"n":82},"feature0_record":{"blocks":[[115424.47391131187,115428.47391131187,10922544.14210909]],"n":245},"record":{"blocks":[[79.0,89.0,1508.0]],"n":70}}}
{"case":"attributes_restore_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1508,183428],[2419,270746],[3057,403507],[6277,856170],[551,3370]],"n":1035},"feature0_graph":{"blocks":[[278,24878]],"n":146}},"topology":null,"values":{"base_geometry":{"blocks":[[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23]],"n":252},"base_record":{"blocks":[[154397.14159265358,154397.14159265358,18832863.4645943],[130360.84159265357,130360.84159265357,15657691.00865363],[60190.0,60190.0,6048065.0]],"n":714},"feature0_geometry":{"blocks":[[1.6e+21,1.6e+21,1.36e+22]],"n":38},"feature0_record":{"blocks":[[57216.03231865831,57216.03231865831,2227522.302144891]],"n":115},"record":{"blocks":[[59.0,67.0,823.0]],"n":55}}}
@@ -56,9 +54,6 @@
{"case":"fillet_group_torii_fillet_rb11","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2573,312590],[2235,302121],[7287,1092477],[3374,249678],[9425,1441704],[5510,902128],[13186,1872602],[16615,2004017],[1666,20910]],"n":2071}},"topology":null,"values":{"base_geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[8e+20,8e+20,3.6e+21],[1.0000200000000001,1.0000200000000001,3.00005]],"n":516},"base_record":{"blocks":[[155035.83440579096,155661.89765365858,18948680.82052572],[155281.77152757163,155574.77152757163,20161266.305777643],[141134.20495401855,141407.41630458267,17778418.33330449],[105017.50209477526,105586.50209477526,11821412.01749248],[91174.28875598182,91629.28875598182,10910463.960739516],[15238.813143693185,15288.813143693185,109863.88984156726]],"n":1327},"record":[0.0,0.0,1.0,0.0,0.0]}}
{"case":"fillet_group_torii_fillet_rb23","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2573,312590],[2235,302121],[7287,1092477],[3374,249678],[9425,1441704],[5510,902128],[13186,1872602],[16615,2004017],[1666,20910]],"n":2071}},"topology":null,"values":{"base_geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[8e+20,8e+20,3.6e+21],[1.0000200000000001,1.0000200000000001,3.00005]],"n":516},"base_record":{"blocks":[[155035.83440579096,155661.89765365858,18948680.82052572],[155281.77152757163,155574.77152757163,20161266.305777643],[141134.20495401855,141407.41630458267,17778418.33330449],[105017.50209477526,105586.50209477526,11821412.01749248],[91174.28875598182,91629.28875598182,10910463.960739516],[15238.813143693185,15288.813143693185,109863.88984156726]],"n":1327},"record":[0.0,0.0,1.0,0.0,0.0]}}
{"case":"mitred_rounds_box_top_r3","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2083,253864],[3024,468943],[3863,321220],[5564,804198],[5754,904563],[11792,1526882],[1838,41095]],"n":1577},"feature0_graph":{"blocks":[[1806,224222],[3712,613264],[2805,507349],[4462,572148],[9492,1266469],[1232,21604]],"n":1313}},"topology":null,"values":{"base_geometry":{"blocks":[[1.91999999999999e+22,1.91999999999999e+22,1.8528000000000002e+24],[782.0829429411759,782.082942941176,29519.122368823526]],"n":383},"base_record":{"blocks":[[156507.5,156507.5,19111185.0],[156364.54117647058,156364.54117647058,20322396.93382353],[105433.0411764706,105433.0411764706,12327102.061764706],[94630.0,95718.0,11327548.0],[210.0,210.0,660.0]],"n":1029},"feature0_geometry":{"blocks":[[1.5999999999999933e+22,1.5999999999999933e+22,1.288e+24],[37.98568732985196,37.98568732985196,788.0151850985815]],"n":313},"feature0_record":{"blocks":[[156703.22494602168,156703.22494602168,19162418.692349844],[143258.58235294116,143258.58235294116,17060525.736758336],[93725.45584412271,94813.45584412271,10865237.012552246],[31834.0,31834.0,749544.5]],"n":851},"record":{"blocks":[[180.0,190.0,3080.0]],"n":52}}}
{"case":"multi_feature_adv_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2764,379783],[2576,339959],[6104,754984],[2151,298312],[7054,677922],[7816,1172299],[13400,1684853],[2574,69936]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176978],[2441,349693],[4111,670924],[774,14683]],"n":804},"feature2_graph":{"blocks":[[1305,198056],[1736,271264],[1944,108295]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[638.8841584798115,775.4505290941705,56271.317757521465]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155094.1600378454,155126.1600378454,20164389.693007357],[116325.18331626526,116365.18331626526,12865628.669316046],[106093.77870914956,106113.77870914956,13675114.593415532],[45943.52615059925,45943.52615059925,3582894.5606996478]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105063.5,105159.5,12655567.5],[75.0,75.0,967.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.34995257392,142729.34995257392,15852336.370148472],[60998.52615059925,60998.52615059925,4010652.7699044417]],"n":423},"record":{"blocks":[[326.0,346.0,13094.0]],"n":124}}}
{"case":"multi_feature_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2771,380151],[2581,340435],[6027,751066],[2174,302712],[6982,679564],[7880,1185829],[13383,1678399],[2591,69948]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176978],[2441,349693],[4111,670924],[774,14683]],"n":804},"feature2_graph":{"blocks":[[1305,197879],[1748,275250],[1932,107087]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115312070845,687.1779218214433,52984.82530328035]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20148234.18077134],[116389.18300344986,116429.18300344986,12864740.769140378],[106102.57306303276,106122.57308303277,13677357.359610839],[45934.73234671234,45934.73240671234,3583931.6460126126]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105063.5,105159.5,12655567.5],[75.0,75.0,967.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15851718.332201496],[60998.526700595554,60998.52678059555,4011681.271218581]],"n":423},"record":{"blocks":[[326.0,346.0,13090.0]],"n":124}}}
{"case":"multi_rebuild_0_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2771,380151],[2581,340435],[6027,751042],[2174,302712],[6995,682597],[7865,1184614],[13385,1678999],[2591,70004]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804},"feature2_graph":{"blocks":[[1305,197879],[1748,275250],[1932,107087]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115312070845,667.1779218214434,52652.187164461]],"n":442},"base_record":{"blocks":[[155451.53865545918,155483.53865545918,18959902.682866313],[155023.60443808086,155068.17080869523,20150495.966324273],[116389.18300344986,116429.18300344986,12864740.769140378],[106102.57306303276,106103.90832107945,13677333.40532395],[45934.73234671234,45934.73240671234,3583931.6460126126]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15851718.332201496],[60998.526700595554,60998.52678059555,4011681.271218581]],"n":423},"record":{"blocks":[[322.0,342.0,12969.0]],"n":127}}}
{"case":"multi_rebuild_1_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2077,266206],[3633,579612],[2138,226476],[4989,506469],[8927,1280475],[3025,147354]],"n":1369},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804},"feature2_graph":{"blocks":[[293,26021]],"n":149}},"topology":null,"values":{"base_geometry":{"blocks":[[1.5999999999999933e+22,1.5999999999999933e+22,1.288e+24],[110.8481596440759,207.41453025843512,3679.688704523623]],"n":343},"base_record":{"blocks":[[155448.14519604263,155480.14519604263,18960494.546111137],[155020.50867205963,155065.07504267397,20143946.545116752],[91052.57229961093,91093.23991863422,11082444.561509266],[45484.33380951166,45485.00142853499,2282412.032856306]],"n":899},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"feature2_geometry":{"blocks":[[1.6e+21,1.6e+21,1.36e+22]],"n":49},"feature2_record":{"blocks":[[71831.53239638661,71831.53239638661,3671408.0937480605]],"n":139},"record":{"blocks":[[287.0,307.0,9792.0]],"n":127}}}
{"case":"multi_rebuild_2_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2207,279844],[3288,509422],[3158,278508],[4947,640592],[6593,1014126],[9416,988308]],"n":1500},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804}},"topology":null,"values":{"base_geometry":{"blocks":[[1.7599999999999916e+22,1.7599999999999916e+22,1.5576000000000002e+24],[152.8067495591884,269.37312017354753,7235.722609831468]],"n":364},"base_record":{"blocks":[[155260.6164362172,155305.18280683155,18929403.35444251],[155162.03743188508,155194.03743188508,20182286.063773222],[91107.57229961093,91148.23991863422,11083020.561509266],[60725.11195932037,60725.779578343696,4299053.8873962285]],"n":940},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"record":{"blocks":[[271.0,287.0,7844.0]],"n":102}}}
{"case":"no_ops_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1728,230155],[2865,397674],[3250,542715],[4992,815381],[4729,327785]],"n":1165},"feature0_none":[1]},"topology":null,"values":{"base_geometry":{"blocks":[[1.3599999999999958e+22,1.3599999999999958e+22,9.316000000000001e+23],[11.000069999999997,11.000069999999997,92.00077000000002]],"n":276},"base_record":{"blocks":[[154398.14159265358,154398.14159265358,18832864.4645943],[129390.13959673682,129390.13959673682,14750795.369363222],[90273.51715728751,90273.51715728751,11239145.200591717],[14.017157287525382,14.017157287525382,85.60294372515229]],"n":779},"record":{"blocks":[[48.0,64.0,438.0]],"n":43}}}
+3 -3
View File
@@ -96,7 +96,7 @@
{"case":"gen0039","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1745.6446517672211,1759.0009086963332,53080.473545951456]],"n":61}}}
{"case":"gen0040","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1293.1780750967052,1331.5983705234573,43731.711951791374]],"n":54}}}
{"case":"gen0041","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-1.512782707950123,1082.7428297938636,-509.6925401146473]],"n":89}}}
{"case":"gen0042","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[247.59500994892622,267.637273216452,10403.498983830963]],"n":67}}}
{"case":"gen0042","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[247.59502854734214,267.6372546180361,10403.49931860245]],"n":67}}}
{"case":"gen0043","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[["inf","inf","inf"]],"n":58}}}
{"case":"gen0044","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[2363.331389460768,2381.342638010389,23505.946664766696]],"n":54}}}
{"case":"gen0045","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[46563.40296431203,46630.561956428515,823550.7565376083]],"n":131}}}
@@ -909,7 +909,7 @@
{"case":"gen0852","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[160.1181334263939,588.6357976688842,7315.822570183931]],"n":78}}}
{"case":"gen0853","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[483.08853328132193,583.7951633580492,21580.922615116793]],"n":68}}}
{"case":"gen0854","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1904.368161806397,1986.2846515331662,41598.7870653989]],"n":65}}}
{"case":"gen0855","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-156.02408784125566,410.5124186336983,-4682.458967341133]],"n":68}}}
{"case":"gen0855","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-156.02409151847633,410.5124223109191,-4682.4589894044575]],"n":68}}}
{"case":"gen0856","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1840.2670017776718,2073.924694260032,52771.46827067364]],"n":81}}}
{"case":"gen0857","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[344.30209778448113,387.76064706770353,11668.607945433409]],"n":56}}}
{"case":"gen0858","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[80.44331673962174,1051.7564644076338,4225.38697740385]],"n":89}}}
@@ -1655,7 +1655,7 @@
{"case":"gen1598","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1107.122912136224,2062.064170269247,18820.15307682447]],"n":100}}}
{"case":"gen1599","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1649.8413176227855,1688.9076570088273,82824.75732259153]],"n":80}}}
{"case":"gen1600","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1328.8306468841884,1537.8237072141824,64621.894244369585]],"n":89}}}
{"case":"gen1601","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[245.62098559210304,260.60129307745285,8049.255707037365]],"n":55}}}
{"case":"gen1601","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[245.6209888918087,260.6012897777472,8049.2557268355995]],"n":55}}}
{"case":"gen1602","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1565.0240348651373,1591.1848258051928,97009.86974045317]],"n":97}}}
{"case":"gen1603","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1295.903574378681,1475.9072442360741,96443.64845655844]],"n":103}}}
{"case":"gen1604","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[44185.678798756344,46310.99065267807,787573.7979235228]],"n":172}}}
+5 -5
View File
@@ -26,7 +26,7 @@
{"case":"edge_negate","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"extent":[-3.0,-1.0]}}
{"case":"edge_periodic_degenerate_wrap","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[3.0,1.0,0.0,0.0,1.0,0.0]}}
{"case":"edge_periodic_huge_value","deferred":null,"error":null,"ints":{"flags":[0,0,1,1]},"topology":null,"values":{"values":[1e+20,1e+20,-1e+20,-1e+20,1.0,-1e+20]}}
{"case":"edge_periodic_near_top","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[89.99999000000003,-9.999999974752427e-6,9.999999974752427e-6,90.00000999999997,-9.999999974752427e-6,9.999999974752427e-6]}}
{"case":"edge_periodic_near_top","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[89.99999000000003,0.0,0.0,90.0,0.0,0.0]}}
{"case":"edge_periodic_negative_period","deferred":null,"error":null,"ints":{"flags":[0,0,1,0]},"topology":null,"values":{"values":[20.0,-160.0,-200.0,-610.0,90.0,-610.0]}}
{"case":"edge_periodic_zero_period","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[1.0,3.0,3.0,2.0,1.0,2.0]}}
{"case":"edge_same_scaled_zero","deferred":null,"error":null,"ints":{"flags":[0,0,0,0,0,0,1]},"topology":null,"values":{"intersect":[5e-13,1000000.0],"union":[0.0,1000000.0000005]}}
@@ -921,7 +921,7 @@
{"case":"gen0867","deferred":null,"error":null,"ints":{},"topology":null,"values":{"extents":[-4032.5445657100436,0.0,-4032.5445657100436,-4032.5445657100436,1e+20,-1e+20]}}
{"case":"gen0868","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-4.547682406915637,-4.547682406915637,2.000177801164682e-12,3.547682406915637,-4.547682406915637,2.000177801164682e-12]}}
{"case":"gen0869","deferred":null,"error":null,"ints":{"disjoint":[0]},"topology":null,"values":{}}
{"case":"gen0870","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-3.999999999999,-3.999999999999,1.000088900582341e-12,0.0,-3.999999999999,0.0]}}
{"case":"gen0870","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-3.999999999999,-4.0,0.0,1.000088900582341e-12,-4.0,0.0]}}
{"case":"gen0871","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-999999.999901,-2.999901,999997.000099,0.00009900000000007125,-2.999901,0.00009900000000007125]}}
{"case":"gen0872","deferred":null,"error":null,"ints":{"status_count":[0,0]},"topology":null,"values":{"intervals":[]}}
{"case":"gen0873","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-420.0475459692425,-5864.660909553929,0.0,-5444.613363584686,-5864.660909553929,-5444.613363584686]}}
@@ -1554,7 +1554,7 @@
{"case":"gen1500","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[1e-6,1e-6,1e+20,1e+20,-1e+20,1e+20]}}
{"case":"gen1501","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.9587557303154033,-0.9587557303154033,4.041244269684597,0.3265002390765175,-0.9587557303154033,0.3265002390765175]}}
{"case":"gen1502","deferred":null,"error":null,"ints":{"counts":[0],"flag":[0]},"topology":null,"values":{"clamped":[-3.6531332205612728]}}
{"case":"gen1503","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.8431568102231495,-0.8412598042009449,3.9968028886505635e-15,3.841259804200945,-0.8412598042009449,3.9968028886505635e-15]}}
{"case":"gen1503","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.8431568102231495,-0.8412598042009409,0.0,3.841259804200941,-0.8412598042009409,0.0]}}
{"case":"gen1504","deferred":null,"error":null,"ints":{"flags":[1,1,0,0,0,1,1,1,1,1,0,0,0]},"topology":null,"values":{"values":[-5.0,8.696559494945177,1.8482797474725885,13.696559494945177,8.696559494945177,0.0,0.0,-5.0,0.0,-5.0,0.3650551805981122]}}
{"case":"gen1505","deferred":null,"error":null,"ints":{"flags":[0,0,0,0,0,0,0]},"topology":null,"values":{"intersect":[2.5565017038286015,3.0],"union":[-7.213387952269873e-10,3.165025619210411]}}
{"case":"gen1506","deferred":null,"error":null,"ints":{"flags":[1,1,0,1]},"topology":null,"values":{"values":[5605.960734602222,5605.960734602222,0.0,0.0,5605.960734602222,0.0]}}
@@ -2114,7 +2114,7 @@
{"case":"gen2060","deferred":null,"error":null,"ints":{"flags":[0,1,0,0,0,1,1,1,0,4,0,0,1001]},"topology":null,"values":{"values":[3564.9311334680096,3564.9311344680095,3564.93113396801,9.99999883788405e-7,3564.9311344680095,3564.9311344680095,3564.9311344680095,3564.93469839883,0.00010000000020227162,3564.9311334680096,101.0000118234325]}}
{"case":"gen2061","deferred":null,"error":null,"ints":{"counts":[],"flag":[0]},"topology":null,"values":{"clamped":[]}}
{"case":"gen2062","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"extent":[2.0,360.0]}}
{"case":"gen2063","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.0001752855794858732,-0.0009064160095650919,2.975050761300224e-16,0.0009064156015238454,-0.0009064160095650919,2.975050761300224e-16]}}
{"case":"gen2063","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.0001752855794858732,-0.0009064160095653894,0.0,0.0009064156015241429,-0.0009064160095653894,0.0]}}
{"case":"gen2064","deferred":null,"error":null,"ints":{"flags":[1001,1,1,0,0,0,0]},"topology":null,"values":{"intersect":[1e+20,-1e+20],"union":[0.465547398032645,1000000.0]}}
{"case":"gen2065","deferred":null,"error":null,"ints":{"flag":[1]},"topology":null,"values":{"clamped":[7.390963598478955,-5.0,-0.8157526895288466,-3.0,2.9999,0.0,-1e+20,-3.3306754088323505,7.774316697598074,4.0,-4.903134706608148,0.0,-1.5550380498551388,-4.0]}}
{"case":"gen2066","deferred":null,"error":null,"ints":{"flags":[1,1,0,1]},"topology":null,"values":{"values":[-3482.7204020819313,-3482.7204020819313,9.99999883788405e-7,3478.7204020819313,-3482.7204020819313,9.99999883788405e-7]}}
@@ -2318,7 +2318,7 @@
{"case":"gen2264","deferred":null,"error":null,"ints":{"status":[1007]},"topology":null,"values":{"extent":[1e+20,-1e+20]}}
{"case":"gen2265","deferred":null,"error":null,"ints":{"flags":[1001,1,1,0,0,0,0]},"topology":null,"values":{"intersect":[1e+20,-1e+20],"union":[-1234567.0,1234567.0]}}
{"case":"gen2266","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[3.5,-0.5005336876101969,0.5,4.500533687610197,-0.0005336876101968466,0.5]}}
{"case":"gen2267","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.005902837378700454,0.000983806229783113,3.131175874138137e-16,2.000983806229783,0.000983806229783113,3.131175874138137e-16]}}
{"case":"gen2267","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.005902837378700454,0.000983806229783426,0.0,2.0009838062297836,0.000983806229783426,0.0]}}
{"case":"gen2268","deferred":null,"error":null,"ints":{},"topology":null,"values":{"approx":[-0.0,0.7741788387164605],"approx_plain":[-0.0,0.7741788387164605],"expanded":[-0.0,0.7741788387164605]}}
{"case":"gen2269","deferred":null,"error":null,"ints":{"flags":[1,1,1,1]},"topology":null,"values":{"values":[4.700315805143418,302.8236045754655,0.0,1.9895196601282805e-12,302.8236045754655,0.0]}}
{"case":"gen2270","deferred":null,"error":null,"ints":{"counts":[],"flag":[0]},"topology":null,"values":{"clamped":[]}}
+1 -1
View File
@@ -3029,7 +3029,7 @@
{"case":"my_test_extent2d_classify_point_tol_v","deferred":null,"error":null,"ints":{"classify":[1,0],"contains":[1,1],"left_eval":[1,1],"on_boundary":[0],"on_boundary_bits":[0,0]},"topology":null,"values":{"binormal":[0.0,0.0],"clamp":[1.1,2.1],"outside_dist":[0.0]}}
{"case":"my_test_extent2d_clip_line","deferred":null,"error":null,"ints":{},"topology":null,"values":{"t":[1.0]}}
{"case":"my_test_extent2d_contains_periodic","deferred":null,"error":null,"ints":{"contained":[0],"contained_again":[0]},"topology":null,"values":{"periodic_point":[4.9,3.0]}}
{"case":"my_test_extent2d_contains_periodic_tol","deferred":null,"error":null,"ints":{"contained":[1],"contained_again":[1]},"topology":null,"values":{"periodic_point":[0.9000000000000004,3.0]}}
{"case":"my_test_extent2d_contains_periodic_tol","deferred":null,"error":null,"ints":{"contained":[1],"contained_again":[1]},"topology":null,"values":{"periodic_point":[1.0,3.0]}}
{"case":"my_test_extent2d_contains_point","deferred":null,"error":null,"ints":{"classify":[4,1],"contains":[0,0],"left_eval":[1,1],"on_boundary":[0],"on_boundary_bits":[0,0]},"topology":null,"values":{"binormal":[0.0,0.0],"clamp":[1.0,3.0],"outside_dist":[0.09999999999999998]}}
{"case":"my_test_extent2d_contains_point_tol","deferred":null,"error":null,"ints":{"classify":[0,1],"contains":[1,1],"left_eval":[1,1],"on_boundary":[1],"on_boundary_bits":[1,1]},"topology":null,"values":{"binormal":[1.0,0.0],"clamp":[1.0,3.0],"outside_dist":[0.0]}}
{"case":"my_test_extent2d_e1_from_points","deferred":null,"error":null,"ints":{},"topology":null,"values":{"extent":[1.0,2.0,3.0,4.0]}}
+5
View File
@@ -1,5 +1,10 @@
{
"blend.feature_executive": {
"add_by_faces_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"attributes_add_by_faces_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"multi_feature_adv_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"multi_feature_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"multi_rebuild_0_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"single_face_fillet1019_6": "tangency-labs/solidcore#3093: success with a body that fails check_brep level 2 (11 lines, the first Bad Repeated CPts)"
},
"blend.fillet_executive_p3": {
+1 -1
View File
@@ -350,7 +350,7 @@
{"case":"adv_sec_b8_e2_m0_f0.0_c2_t0.0_0.0","deferred":null,"error":"rejected","ints":{},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m0_f0.0_c2_t0.5_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m0_f0.5_c1_t0.5_0.5","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m0_f1e-09_c2_t0.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,1,5]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m0_f1e-09_c2_t0.0_0.0","deferred":null,"error":"rejected","ints":{},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m1_f0.5_c1_t0.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m1_f0.5_c1_t1.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m2_f0.5_c2_t0.25_0.75","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
+6 -6
View File
@@ -11,7 +11,6 @@
use std::cell::RefCell;
use solidcore::kernel::Kernel;
use solidcore::mass::enclosure::VolumeSign;
use solidcore::topology::brep::validation;
use solidcore::topology::Brep;
@@ -26,8 +25,8 @@ pub struct Soundness {
pub check: Result<Vec<String>, String>,
/// Whether the body is a manifold solid.
pub manifold: Option<bool>,
/// Its volume's sign, when it is a manifold solid.
pub volume: Option<VolumeSign>,
/// Its volume, when it is a manifold solid.
pub volume: Option<f64>,
/// Its face, edge and vertex counts.
pub counts: [usize; 3],
}
@@ -36,7 +35,7 @@ impl Soundness {
/// Whether the check is clean and a manifold body has a positive volume.
pub fn sound(&self) -> bool {
self.check.as_ref().is_ok_and(Vec::is_empty)
&& (self.manifold != Some(true) || matches!(self.volume, Some(VolumeSign::Positive(_))))
&& (self.manifold != Some(true) || self.volume.is_some_and(|v| v > 0.0))
}
/// One line: `sound`, the check's line count, manifold, volume and counts.
@@ -165,7 +164,7 @@ pub fn probe(runner: &dyn crate::UnitDiff) {
#[cfg(test)]
mod tests {
use super::{measure, start, take, VolumeSign};
use super::{measure, start, take};
use solidcore::kernel::Kernel;
fn cube() -> solidcore::topology::Brep {
@@ -199,7 +198,8 @@ mod tests {
assert!(s.sound(), "{}", s.line());
assert_eq!(s.manifold, Some(true));
assert!(
matches!(s.volume, Some(VolumeSign::Positive(e)) if e.lo <= 1000.0 && 1000.0 <= e.hi),
s.volume
.is_some_and(|v| (v - 1000.0).abs() <= 1e-6 * 1000.0),
"{}",
s.line()
);
+1 -2
View File
@@ -5,8 +5,7 @@ edition.workspace = true
license.workspace = true
version.workspace = true
publish = false
# solidcore-record needs Rust 1.88 (edition 2024 let chains).
rust-version = "1.88"
rust-version.workspace = true
repository = "https://github.com/tangency-labs/solidcore"
keywords = ["cad", "brep", "nurbs", "geometry"]
categories = ["data-structures", "science"]
+7 -151
View File
@@ -21,8 +21,6 @@
#![allow(clippy::field_reassign_with_default)]
#![allow(clippy::neg_cmp_op_on_partial_ord)] // NaN-refusing tests
use std::collections::BTreeMap;
use solidcore::curve::curve::Curve as KernelCurve;
use solidcore::curve::from_parameters::{
self as curves, approx_tolerance, zone_tolerance, MAX_TOLERANCE,
@@ -95,86 +93,6 @@ pub struct Order {
pub vertices: Vec<VertexId>,
}
/// A kernel edge solids touch along, as [`to_record`] writes it: one record edge per pair of
/// faces it separates (SolidCraft's `history::Piece`). An edge with uses on three or more faces
/// has one [`Piece`] per record edge [`to_record`] copied it into, each with the faces of that
/// copy's own uses (two, for the sector pairs the radial order gives); none is merged or split
/// here.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Piece {
/// The kernel edge.
pub kernel: EdgeId,
/// One of its record edges.
pub edge: u32,
/// The record faces that record edge bounds, ascending.
pub faces: Vec<u32>,
}
impl Order {
/// Each kernel face's record face (the inverse of [`Order::faces`]).
#[must_use]
pub fn record_faces(&self) -> BTreeMap<FaceId, u32> {
inverse(&self.faces)
}
/// Each kernel edge's first record edge (the inverse of [`Order::edges`]; a kernel edge
/// solids touch along has more, its [`Order::pieces`]).
#[must_use]
pub fn record_edges(&self) -> BTreeMap<EdgeId, u32> {
inverse(&self.edges)
}
/// Each kernel vertex's first record vertex (the inverse of [`Order::vertices`]).
#[must_use]
pub fn record_vertices(&self) -> BTreeMap<VertexId, u32> {
inverse(&self.vertices)
}
/// Every record edge of each kernel edge that has more than one, in record order, with the
/// record faces it bounds (`record` is the record this order came with): the faces of that
/// record edge's uses, sorted and without repeats.
///
/// # Errors
///
/// [`Unsupported::Invalid`] for a record whose edges are not this order's.
pub fn pieces(&self, record: &r::Brep) -> R<Vec<Piece>> {
if record.edges.len() != self.edges.len() {
return Err(Unsupported::Invalid("a record not of this order"));
}
let mut count: BTreeMap<EdgeId, usize> = BTreeMap::new();
for &e in &self.edges {
*count.entry(e).or_default() += 1;
}
let mut out = Vec::new();
for (i, (&kernel, e)) in self.edges.iter().zip(&record.edges).enumerate() {
if count.get(&kernel).copied().unwrap_or(0) < 2 {
continue;
}
let mut faces: Vec<u32> = e.uses.iter().map(|u| u.face).collect();
faces.sort_unstable();
faces.dedup();
out.push(Piece {
kernel,
edge: u32::try_from(i).map_err(|_| Unsupported::Invalid("index"))?,
faces,
});
}
Ok(out)
}
}
/// Each entity's first index in `list`. An index past `u32` is left out: unreachable, since
/// every list is an [`Order`]'s, indexed by a record's `u32` indices.
fn inverse<T: Copy + Ord>(list: &[T]) -> BTreeMap<T, u32> {
let mut out = BTreeMap::new();
for (i, &x) in list.iter().enumerate() {
if let Ok(i) = u32::try_from(i) {
out.entry(x).or_insert(i);
}
}
out
}
fn int(n: usize) -> R<u64> {
u64::try_from(n).map_err(|_| Unsupported::Invalid("count"))
}
@@ -313,8 +231,8 @@ fn build(
}
/// The record as the kernel's B-rep data: edgeuses face by face, loop by loop, in loop order;
/// edges and vertices in the record's order. Each edge and vertex keeps its own tolerance; the
/// body's and each face's are the least of theirs ([`own_tolerances`]). The record has no face
/// edges and vertices in the record's order. Each edge and vertex keeps its own tolerance (at
/// least the body's); the body and every face take the body's. The record has no face
/// tolerance: a face's own crosses as its edges' (`to_record` puts it into them).
fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
let zone = zone_tolerance(rec.tolerance);
@@ -400,7 +318,7 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
// Edges first (their kernel intervals are the trims' parameter), in the record's order.
let mut intervals = Vec::new();
for e in &rec.edges {
let tolerance = e.tolerance;
let tolerance = e.tolerance.max(rec.tolerance);
if !(tolerance <= MAX_TOLERANCE) {
return Err(Unsupported::NotYet(
"an edge tolerance over the kernel's limit",
@@ -557,18 +475,9 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
let interval = intervals
.get(u.edge as usize)
.ok_or(Unsupported::Invalid("edge"))?;
let source_interval = rec
.edges
.get(u.edge as usize)
.ok_or(Unsupported::Invalid("edge"))?
.interval;
ud.uv_curve = int(d.uv_curves.len())?;
let trim = curves::uv_trim_on_edge(
&crate::curve2(&u.pcurve),
source_interval,
interval,
made.scale,
)?;
let trim =
curves::uv_trim(&crate::curve2(&u.pcurve), interval, made.scale)?;
d.uv_curves.push(Keyed {
key: key(),
object: trim,
@@ -591,9 +500,9 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
}
}
// Vertices, each with its own tolerance.
// Vertices, each with its own tolerance, never below the body's.
for v in &rec.vertices {
let tolerance = v.tolerance;
let tolerance = v.tolerance.max(rec.tolerance);
if !(tolerance <= MAX_TOLERANCE) {
return Err(Unsupported::NotYet(
"a vertex tolerance over the kernel's limit",
@@ -605,62 +514,9 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
vd.point = Vec3::new(v.point[0], v.point[1], v.point[2]);
d.vertices.push(vd);
}
own_tolerances(&mut d, rec, zone)?;
Ok(d)
}
/// The body's and each face's tolerance from the edges' and vertices' own, as SolidCraft's
/// flat import took them (`own_tolerances`, after the capi's): the kernel's build raises every
/// entity to the body's, so the body's is the least of the record's and its edges' and
/// vertices', and a face's the least of its loops' edges' and vertices', at most the record's.
/// Each entity keeps its own across the crossing, none raised to the body's.
///
/// This keeps invariant 5 of `docs/validity-and-tolerances.md` §10 (every local tolerance at
/// least the body's) by the other route from raising each entity to the body's (8252ae3e93,
/// "never below the body's"): the body comes down to the least of them instead, so no entity
/// is below it and none is changed. A face's is at least the body's (both are minima, the
/// body's over more) and at most its loops' edges' and vertices'.
///
/// # Errors
///
/// [`Unsupported::Invalid`] when the least zone is not positive and finite: a tolerance so
/// small that its zone (half of it) underflows to zero passes the record's validation.
fn own_tolerances(d: &mut BrepData, rec: &r::Brep, zone: f64) -> R<()> {
let edge_zone = |e: u32| -> R<f64> { Ok(at(&d.edges, e)?.zone_tol_3d) };
let vertex_zone = |v: u32| -> R<f64> { Ok(at(&d.vertices, v)?.zone_tol_3d) };
let mut faces = Vec::with_capacity(rec.faces.len());
for f in &rec.faces {
let mut t = f64::INFINITY;
for l in &f.loops {
match l {
r::Loop::Edges { edges } => {
for oe in edges {
t = t.min(edge_zone(oe.edge)?);
}
}
r::Loop::Vertex { vertex } => t = t.min(vertex_zone(*vertex)?),
}
}
faces.push(if t.is_finite() { t.min(zone) } else { zone });
}
let least = d
.edges
.iter()
.map(|e| e.zone_tol_3d)
.chain(d.vertices.iter().map(|v| v.zone_tol_3d))
.fold(zone, f64::min);
if !(least.is_finite() && least > 0.0) {
return Err(Unsupported::Invalid(
"a tolerance whose zone (half of it) is zero or not finite",
));
}
d.zone_tol_3d = least;
for (fd, t) in d.faces.iter_mut().zip(faces) {
fd.zone_tol_3d = t;
}
Ok(())
}
/// The curves of a ball patch face's loops, for [`face_surface`] to certify on its sphere:
/// each edge's NURBS form, or its curve; empty for any other face, whose label is not
/// checked against its loops.
+1 -1
View File
@@ -3,7 +3,7 @@
//! B-rep data, with no arrays between. What is done here is index mapping and field copies:
//!
//! - **Geometry:** each edge's curve and each face's surface as the kernel writes its record
//! ([`record_of`], [`record_face`]): a line as the kernel holds it, a conic by its own frame and
//! ([`record_of`], [`record_face`]): a line by its two end points, a conic by its own frame and
//! its NURBS form, proved on each other; an analytic face by its label and its NURBS form,
//! proved (`lies_on`); a plane by its placement, its parameters its kernel ones times its
//! scales. Nothing is sampled. A curve or surface with no NURBS form (an offset) is refused.
+4 -76
View File
@@ -226,29 +226,6 @@ fn a_cube_record_builds_the_kernels_cube() {
}
}
#[test]
fn a_pcurve_support_may_extend_past_its_edge_interval() {
let mut record = cube(1.0);
let Curve::Line { start, end } = &record.edges[0].curve else {
panic!("cube line")
};
let (start, end) = (*start, *end);
record.edges[0].curve = Curve::Spline {
knots: vec![0.0, 0.0, 1.0, 1.0],
points: vec![start, end],
};
record.edges[0].interval = [0.0, f64::from_bits(1.0_f64.to_bits() - 2)];
record.validate().unwrap();
let original = record.faces.clone();
let mut k = kernel();
let (mut body, _) = to_kernel(&mut k, &record, true).unwrap();
let (saved, _) = to_record(&mut k, &mut body).unwrap();
assert_eq!(
saved.faces.iter().map(|f| &f.loops).collect::<Vec<_>>(),
original.iter().map(|f| &f.loops).collect::<Vec<_>>()
);
}
#[test]
fn records_the_conversion_cannot_carry_are_refused() {
let mut k = kernel();
@@ -579,8 +556,8 @@ fn lumps_and_solids_touching_along_an_edge_go_round() {
}
/// An analytic body's record: its curved faces are labels on their NURBS forms, its circles
/// labels on theirs, its lines as the kernel holds them (its point and unit vector, over the
/// height); each pcurve on its edge's interval.
/// labels on theirs, its lines their two end points over [0, 1]; each pcurve on its edge's
/// interval.
#[test]
fn analytic_records_carry_labels_on_their_forms() {
let mut k = kernel();
@@ -599,9 +576,8 @@ fn analytic_records_carry_labels_on_their_forms() {
for e in &r.edges {
match &e.curve {
Curve::Line { start, end } => {
// as the kernel holds the seam: its point and unit vector, over its height
assert_eq!(e.interval, [0.0, 5.0]);
assert_eq!((start[2], end[2]), (0.0, 1.0));
assert_eq!(e.interval, [0.0, 1.0]);
assert_eq!((start[2], end[2]), (0.0, 5.0));
assert!(e.nurbs.is_none());
}
Curve::Circle { radius, .. } => {
@@ -1170,51 +1146,3 @@ fn a_ball_patch_far_from_the_origin_reads_as_one() {
assert!(read_ball(&mut k, &off, i).is_none(), "face {i}");
}
}
/// A record whose body tolerance is above its edges' and vertices' builds with the body's
/// lowered to the least of them (the flat import's `own_tolerances`): valid at Level 2, and
/// every edge and vertex at least the body's (invariant 5 of `docs/validity-and-tolerances.md`
/// §10), each at its own (the zone, half the record's), none raised.
#[test]
fn a_body_tolerance_above_its_entities_comes_down_to_theirs() {
let mut k = kernel();
let mut r = cube(10.0);
r.tolerance = 1e-4;
r.edges[3].tolerance = 4e-6;
r.vertices[5].tolerance = 2e-6;
r.validate().unwrap();
let (mut b, _) = to_kernel(&mut k, &r, true).unwrap();
valid(&mut k, &mut b);
// the least is the other edges' 1e-6, as a zone: half of it
assert_eq!(b.tolerance(), 5e-7);
for &e in b.edge_list() {
assert!(b.edge(e).unwrap().tolerance >= b.tolerance(), "edge {e:?}");
}
for &v in b.vertex_list() {
assert!(
b.vertex(v).unwrap().tolerance >= b.tolerance(),
"vertex {v:?}"
);
}
let mut own: Vec<f64> = b
.edge_list()
.iter()
.map(|&e| b.edge(e).unwrap().tolerance)
.collect();
own.sort_by(f64::total_cmp);
own.dedup();
assert_eq!(own, vec![5e-7, 2e-6], "edges keep their own zones");
}
/// A tolerance so small that its zone (half of it) underflows to zero passes the record's
/// validation and is refused by the conversion, not dropped to the body's.
#[test]
fn a_tolerance_whose_zone_underflows_is_refused() {
let mut k = kernel();
let mut r = cube(1.0);
r.vertices[0].tolerance = f64::from_bits(1);
assert!(matches!(
to_kernel(&mut k, &r, true),
Err(Unsupported::Invalid(_))
));
}
-604
View File
@@ -1,604 +0,0 @@
//! Parity with SolidCraft's flat-array hop (#3137): every operand record of the rig corpus
//! (`crates/solidcore/tests/kernel_cases/`) is what SolidCraft's flat export wrote (record →
//! arrays → kernel → arrays → record), or, for a body SolidCraft made or read itself (not
//! `trusted`), what it handed the kernel. Each goes to the kernel ([`to_kernel`]) and back
//! ([`to_record`]); the record that comes back is compared with the one that went in, field by
//! field, bit for bit. A difference is classed by where it is (indices dropped) and, for a
//! kind, by what it became; a number within [`ROUNDING`] of its size (relative, no floor) is
//! counted as rounding, with its count and the largest.
//!
//! On the whole corpus (1,332 bodies, at #3187): 1,256 come back bit for bit, 36 within
//! rounding (the largest 4.9e-16 relative), and 40 differ, each in exactly the classes
//! [`pinned`] lists for it.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::print_stderr
)] // test
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use serde_json::Value;
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
use solidcore_convert::brep::{to_kernel, to_kernel_body, to_record, Unsupported};
use solidcore_record::brep::Brep as Record;
fn corpus() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../solidcore/tests/kernel_cases")
}
fn entries(dir: &Path, out: &mut Vec<PathBuf>) {
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
paths.sort();
for p in paths {
if p.is_dir() {
entries(&p, out);
} else if p.extension().is_some_and(|x| x == "json")
&& !p.to_string_lossy().ends_with(".bodies.json")
&& p.parent() != Some(corpus().as_path())
{
out.push(p);
}
}
}
#[derive(Default)]
struct Class {
bodies: usize,
places: usize,
examples: Vec<String>,
}
#[derive(Default)]
struct Tally {
bodies: usize,
identical: usize,
rounding_only: usize,
rounding_places: usize,
rounding_largest: f64,
refused_in: Vec<String>,
/// Sheets with a face used on its reverse side (every difference follows from that).
sheet_side: Vec<String>,
refused_out: Vec<String>,
classes: BTreeMap<String, Class>,
/// Each differing body's classes (sheet-side bodies' too).
per_body: BTreeMap<String, BTreeSet<String>>,
/// Bodies whose `.tolerance` moved other than down to the least of the record's.
tolerance_wrong: Vec<String>,
/// Sheet-side bodies whose geometry differs as multisets (not only renumbered).
sheet_geometry: Vec<String>,
/// (body, face, the record's kind, the kind that came back, trusted)
relabelled: Vec<(String, usize, String, String, bool)>,
}
/// The relative size of a difference counted as rounding, not a class: a few ulps.
const ROUNDING: f64 = 1e-15;
fn normal(path: &str) -> String {
let mut out = String::new();
let mut in_index = false;
for c in path.chars() {
match c {
'[' => {
in_index = true;
out.push_str("[*]");
}
']' => in_index = false,
_ if in_index => {}
_ => out.push(c),
}
}
out
}
/// Every place `a` and `b` differ: (class, place); numbers within [`ROUNDING`] of their size
/// (relative, no floor) counted apart, with the largest relative difference.
fn walk(
at: &str,
a: &Value,
b: &Value,
diffs: &mut Vec<(String, String)>,
rounding: &mut (usize, f64),
) {
if a == b {
return;
}
if let (Some(x), Some(y)) = (a.as_f64(), b.as_f64()) {
let rel = (x - y).abs() / x.abs().max(y.abs());
if rel <= ROUNDING {
rounding.0 += 1;
rounding.1 = rounding.1.max(rel);
return;
}
diffs.push((normal(at), format!("{at}: {x} -> {y}")));
return;
}
match (a, b) {
(Value::Object(x), Value::Object(y)) => {
for (k, v) in x {
walk(
&format!("{at}.{k}"),
v,
y.get(k).unwrap_or(&Value::Null),
diffs,
rounding,
);
}
for (k, w) in y {
if !x.contains_key(k) {
walk(&format!("{at}.{k}"), &Value::Null, w, diffs, rounding);
}
}
}
(Value::Array(x), Value::Array(y)) if x.len() == y.len() => {
for (i, (v, w)) in x.iter().zip(y).enumerate() {
walk(&format!("{at}[{i}]"), v, w, diffs, rounding);
}
}
(Value::Array(x), Value::Array(y)) => diffs.push((
format!("{}: length", normal(at)),
format!("{at}: {} -> {}", x.len(), y.len()),
)),
(Value::String(x), Value::String(y)) => {
diffs.push((format!("{}: {x} -> {y}", normal(at)), at.to_owned()));
}
_ => {
let short = |v: &Value| {
let s = v.to_string();
s.chars().take(60).collect::<String>()
};
diffs.push((normal(at), format!("{at}: {} -> {}", short(a), short(b))));
}
}
}
/// A record's geometry as multisets, its numbering dropped: the vertex points, each edge's
/// curve (a line's two ends unordered), each face's surface and NURBS form.
fn geometry(r: &Value) -> [Vec<String>; 3] {
let list = |key: &str, f: &dyn Fn(&Value) -> String| -> Vec<String> {
let mut v: Vec<String> = r[key]
.as_array()
.map(|a| a.iter().map(f).collect())
.unwrap_or_default();
v.sort();
v
};
let curve = |e: &Value| {
let c = &e["curve"];
if c["kind"] == "line" {
let mut ends = [c["start"].to_string(), c["end"].to_string()];
ends.sort();
format!("line {ends:?}")
} else {
c.to_string()
}
};
[
list("vertices", &|v| v["point"].to_string()),
list("edges", &curve),
list("faces", &|f| format!("{} {}", f["surface"], f["nurbs"])),
]
}
fn kind(face: &Value) -> String {
face["surface"]["kind"].as_str().unwrap_or("?").to_owned()
}
fn one(name: &str, record: &Record, trusted: bool, fixes: &UpstreamFixes, t: &mut Tally) {
t.bodies += 1;
// a sheet face used on its reverse side: the kernel keeps both uses of a sheet's face and
// the record comes back with the `same` one, as SolidCraft's flat export wrote it
// (a sheet's shell is listed by one region; a closed shell by the two it separates)
let listed = |i: usize| {
record
.regions
.iter()
.filter(|r| r.shells.iter().any(|&s| s as usize == i))
.count()
};
let sheet_side = record.shells.iter().enumerate().any(|(i, s)| match s {
solidcore_record::brep::Shell::Faces { faces, .. } => {
listed(i) == 1 && faces.iter().any(|f| !f.forward)
}
_ => false,
});
let mut k = Kernel::new(&KernelOptions::default());
k.fixes = *fixes;
let built = match to_kernel(&mut k, record, trusted) {
Ok((b, _)) => Ok(b),
Err(Unsupported::NotYet(w)) if w.contains("healer") => {
to_kernel_body(&mut k, record, trusted)
}
Err(e) => Err(e),
};
let mut b = match built {
Ok(b) => b,
Err(e) => {
t.refused_in.push(format!("{name}: {e}"));
return;
}
};
let back = match to_record(&mut k, &mut b) {
Ok((r, _)) => r,
Err(e) => {
t.refused_out.push(format!("{name}: {e}"));
return;
}
};
let (a, z) = (
serde_json::to_value(record).unwrap(),
serde_json::to_value(&back).unwrap(),
);
// `PARITY_DUMP=<dir>` and `PARITY_ONLY=<part of a name>`: both records written there
if let (Ok(dir), Ok(only)) = (std::env::var("PARITY_DUMP"), std::env::var("PARITY_ONLY")) {
if name.contains(&only) {
let stem = name.replace(['/', ':'], "_");
std::fs::write(format!("{dir}/{stem}.in.json"), a.to_string()).unwrap();
std::fs::write(format!("{dir}/{stem}.out.json"), z.to_string()).unwrap();
}
}
let mut diffs = Vec::new();
let mut rounding = (0, 0.0_f64);
walk("", &a, &z, &mut diffs, &mut rounding);
t.rounding_places += rounding.0;
t.rounding_largest = t.rounding_largest.max(rounding.1);
if let (Some(fa), Some(fz)) = (a["faces"].as_array(), z["faces"].as_array()) {
for (i, (x, y)) in fa.iter().zip(fz).enumerate() {
if kind(x) != kind(y) {
t.relabelled
.push((name.to_owned(), i, kind(x), kind(y), trusted));
}
}
}
if diffs.is_empty() {
if rounding.0 == 0 {
t.identical += 1;
} else {
t.rounding_only += 1;
}
return;
}
let classes: BTreeSet<String> = diffs.iter().map(|(c, _)| c.clone()).collect();
if classes.contains(".tolerance") {
// lowered, to the least of the record's own (the flat import's `own_tolerances`)
let least = record
.edges
.iter()
.map(|e| e.tolerance)
.chain(record.vertices.iter().map(|v| v.tolerance))
.fold(record.tolerance, f64::min);
if !(back.tolerance < record.tolerance && back.tolerance == least) {
t.tolerance_wrong.push(format!(
"{name}: {} -> {} (least {least})",
record.tolerance, back.tolerance
));
}
}
t.per_body.insert(name.to_owned(), classes);
if sheet_side && geometry(&a) != geometry(&z) {
t.sheet_geometry.push(name.to_owned());
}
if sheet_side {
t.sheet_side.push(name.to_owned());
return;
}
let mut seen = BTreeMap::new();
for (class, place) in diffs {
seen.entry(class).or_insert((0, place)).0 += 1;
}
for (class, (n, place)) in seen {
let tag = if trusted { "" } else { "(untrusted) " };
let c = t.classes.entry(format!("{tag}{class}")).or_default();
c.bodies += 1;
c.places += n;
if c.examples.len() < 3 {
c.examples.push(format!("{name} {place}"));
}
}
}
fn fixes() -> UpstreamFixes {
let fixes_json: Value =
serde_json::from_str(&std::fs::read_to_string(corpus().join("fixes.json")).unwrap())
.unwrap();
let mut fixes = UpstreamFixes::default();
for flag in fixes_json.as_array().unwrap() {
*fixes.flag_mut(flag.as_str().unwrap()).unwrap() = true;
}
fixes
}
const LABEL: [&str; 6] = [
".faces[*].nurbs",
".faces[*].surface.frame",
".faces[*].surface.homogeneous",
".faces[*].surface.u_knots",
".faces[*].surface.v_knots",
"",
];
/// Every body that does not come back bit for bit (or within [`ROUNDING`]), with exactly the
/// classes it differs in: a body that newly differs, one that no longer does, and one whose
/// classes change all fail (#3187 review). Each group says why its difference is intended.
fn pinned() -> BTreeMap<String, BTreeSet<String>> {
let set = |classes: &[&str]| -> BTreeSet<String> {
classes
.iter()
.filter(|c| !c.is_empty())
.map(|c| (*c).to_owned())
.collect()
};
let label = |kind: &str, radii: &[&str]| {
let mut c = set(&LABEL);
c.insert(format!(".faces[*].surface.kind: {kind}"));
c.extend(radii.iter().map(|r| format!(".faces[*].surface.{r}")));
c
};
let groups: Vec<(BTreeSet<String>, &[&str])> = vec![
// a record whose body tolerance is above its least entity's: the import lowers it to
// that least, as the flat import's `own_tolerances` does (checked: only lowered, and
// only to the least of the record's own)
(
set(&[".tolerance"]),
&[
"continuity/cov-patch-g1-caps-an-opened-tube-end.json:b3",
"continuity/cov-patch-g2-caps-an-opened-tube-end.json:b3",
"continuity/g1-patch-g2-at-a-corner-whose-rounds-clash.json:b13",
"continuity/g1-patch-g3-at-a-corner-whose-rounds-clash.json:b13",
"continuity/g2-patch-g2-on-a-hole-in-one-sheet.json:b11",
"continuity/g3-patch-g3-on-a-hole-in-one-sheet.json:b11",
"fillets/9-fillet-corner-r5-curvature-default-setback.json:b8",
"fillets/cov-fillet-g2-pocket-corner.json:b18",
"fillets/every-box-corner-three-edges-r3-curvature-setback-3-5.json:b8",
"fillets/report-fillet-curvature-three-edges-at-bar-corner-solver-residues.json:b18",
"fillets/report-fillet-curvature-three-edges-at-bar-corner.json:b18",
"fillets/report-fillet-g2-corner-of-bar-running-into-cylinder.json:b18",
"ladder/07-fillets/015-corner-g2-patch.json:b8",
"ladder/07-fillets/019-concave-corner-g2-patch.json:b18",
"surfaces/cov-blend-step-between-offset-sheets-g1.json:b1",
"surfaces/cov-blend-step-between-offset-sheets-g2.json:b1",
"surfaces/cov-blend-step-between-offset-sheets-g3.json:b1",
"surfaces/cov-patch-g0-fills-a-240-deg-window-in-a-cylinder.json:b13",
"surfaces/cov-patch-g0-fills-a-saddle-window-in-a-torus.json:b13",
"surfaces/cov-patch-g1-fills-a-240-deg-window-in-a-cylinder.json:b13",
"surfaces/cov-patch-g1-fills-a-saddle-window-in-a-torus.json:b13",
"surfaces/cov-patch-open-box-rim-and-stitch-closes-it.json:b3",
],
),
// a plane through three points: the kernel's plane holds no record of which kind it
// came from, and the flat export never writes this kind
(
set(&[
".faces[*].surface.kind: plane_through -> plane",
".faces[*].surface.u_axis",
".faces[*].surface.u_point",
".faces[*].surface.v_axis",
".faces[*].surface.v_point",
]),
&[
"surfaces/cov-ruled-box-edge-along-the-top-normal.json:b2",
"surfaces/cov-ruled-closed-top-chain-tangent-mitred-frame.json:b2",
"surfaces/cov-ruled-cylinder-rim-tangent-to-the-top-is-a-flat-ring.json:b2",
],
),
// a label its form is not proved on goes as the form alone (#2540): cylinders whose
// frames are about 1e-14 rad off their forms (two of them at the rounding of a derived
// bound, #3185's thread)
(
label("cylinder -> rational_spline", &["radius"]),
&[
"booleans/3456-deep-cut-through-drilled-taper-leaves-base.json:b94",
"crashes/2243-step-export-flanged-enclosure.json:b43",
"fillets/2903-original-rim-chamfer.json:b0",
"fillets/2903-original-rim-round.json:b0",
],
),
// tori whose frame and fitted sweep are about 1e-14 rad off their forms, rightly
// refused (#3185)
(
label(
"torus -> rational_spline",
&["major_radius", "minor_radius"],
),
&[
"sweeps/3897-sweep-ring-along-a-short-arc-joined-to-a-collar.json:b6",
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub.json:b46",
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub.json:b85",
],
),
// an extruded circle the flat hop left a spline, proved a cylinder
(
label("rational_spline -> cylinder", &["radius"]),
&[
"tessellation/1615-crossed-lofted-tubes.json:b3",
"tessellation/1615-crossed-lofted-tubes.json:b6",
],
),
// a line end the export wrote as 3.1e-16 (a cos 90 deg residue) where the kernel's
// line, point plus its vector, holds 0: an absolute 3e-16 on a coordinate of size 0
(
set(&[".edges[*].curve.end[*]"]),
&["assemblies/cov-contact-check-touching-faces-do-not-overlap.json:b725"],
),
];
let mut out = BTreeMap::new();
for (classes, bodies) in groups {
for b in bodies {
out.insert((*b).to_owned(), classes.clone());
}
}
// a sheet face used on its reverse side: the kernel keeps both uses of a sheet's face and
// the record comes back with the `same` one, as the flat export's rule says; every
// difference is the renumbering that follows (checked: the same vertex points, edge curves
// and face surfaces, as multisets), and two of them also lower `.tolerance`
let renumbered = [
".edges[*].uses[*].position",
".faces[*].loops[*].edges[*].edge",
".shells[*].faces[*].forward",
".vertices[*].point[*]",
];
let match_sheet = [
".edges[*].curve.knots: length",
".edges[*].curve.points: length",
".edges[*].uses[*].upper",
];
let reverse = [
".edges[*].curve.end[*]",
".edges[*].curve.start[*]",
".edges[*].vertices[*]",
];
let with = |extra: &[&[&str]]| {
let mut c = set(&renumbered);
for e in extra {
c.extend(set(e));
}
c
};
for (b, classes) in [
(
"surfaces/cov-match-coplanar-sheet-onto-the-near-edge.json:b1",
with(&[&match_sheet]),
),
(
"surfaces/cov-match-raised-sheet-down-onto-the-edge-g1.json:b1",
with(&[&match_sheet, &[".tolerance"]]),
),
(
"surfaces/cov-match-raised-sheet-down-onto-the-edge-g3.json:b1",
with(&[&match_sheet, &[".tolerance"]]),
),
(
"surfaces/cov-reverse-normal-l-sheet-swaps-the-thicken-side.json:b2",
with(&[&reverse, &[".edges[*].uses: length"]]),
),
(
"surfaces/cov-reverse-normal-then-thicken-goes-down.json:b1",
with(&[&reverse]),
),
] {
out.insert(b.to_owned(), classes);
}
out
}
/// Every `stride`th entry; the report is written to `$PARITY_OUT` when set. No body is refused
/// either way, and every difference is a named one.
fn parity(stride: usize) {
let mut files = Vec::new();
entries(&corpus(), &mut files);
let fixes = fixes();
let mut t = Tally::default();
let with_bodies = files.iter().filter_map(|f| {
let e: Value = serde_json::from_str(&std::fs::read_to_string(f).unwrap()).unwrap();
let bodies = e["bodies"].as_object()?.clone();
(!bodies.is_empty()).then_some((f.clone(), bodies))
});
for (f, bodies) in with_bodies.step_by(stride) {
let gz = f.with_extension("bodies.json.gz");
let mut text = String::new();
flate2::read::GzDecoder::new(std::fs::File::open(&gz).unwrap())
.read_to_string(&mut text)
.unwrap();
let records: Value = serde_json::from_str(&text).unwrap();
let short = f.strip_prefix(corpus()).unwrap_or(&f).display().to_string();
for (id, how) in bodies {
let record: Record = serde_json::from_value(records[&id].clone()).unwrap();
one(
&format!("{short}:{id}"),
&record,
how["trusted"].as_bool().unwrap_or(true),
&fixes,
&mut t,
);
}
}
let mut report = String::new();
let _ = writeln!(
report,
"bodies {} identical {} rounding-only {} (places {}, largest {:e}) differing {} refused in {} refused out {}",
t.bodies,
t.identical,
t.rounding_only,
t.rounding_places,
t.rounding_largest,
t.bodies - t.identical - t.rounding_only - t.refused_in.len() - t.refused_out.len(),
t.refused_in.len(),
t.refused_out.len()
);
for (class, c) in &t.classes {
let _ = writeln!(
report,
"CLASS {class} | bodies {} places {}",
c.bodies, c.places
);
for e in &c.examples {
let _ = writeln!(report, " {e}");
}
}
for n in &t.sheet_side {
let _ = writeln!(report, "SHEET-SIDE {n}");
}
for (n, classes) in &t.per_body {
let list: Vec<&str> = classes.iter().map(String::as_str).collect();
let _ = writeln!(report, "BODY {n} | {}", list.join(" ; "));
}
for w in &t.tolerance_wrong {
let _ = writeln!(report, "TOLERANCE-WRONG {w}");
}
for (n, f, a, b, trusted) in &t.relabelled {
let _ = writeln!(report, "RELABEL {n} face {f}: {a} -> {b} trusted {trusted}");
}
for r in &t.refused_in {
let _ = writeln!(report, "REFUSED-IN {r}");
}
for r in &t.refused_out {
let _ = writeln!(report, "REFUSED-OUT {r}");
}
if let Ok(p) = std::env::var("PARITY_OUT") {
std::fs::write(p, &report).unwrap();
}
eprintln!("{}", report.lines().next().unwrap_or(""));
assert!(t.bodies > 0);
assert!(t.refused_in.is_empty(), "{:#?}", t.refused_in);
assert!(t.refused_out.is_empty(), "{:#?}", t.refused_out);
assert!(t.tolerance_wrong.is_empty(), "{:#?}", t.tolerance_wrong);
assert!(t.sheet_geometry.is_empty(), "{:#?}", t.sheet_geometry);
let pinned = pinned();
let seen: BTreeMap<&String, &BTreeSet<String>> = t.per_body.iter().collect();
for (b, classes) in &seen {
assert_eq!(pinned.get(*b), Some(*classes), "{b}: classes not as pinned");
}
if stride == 1 {
let missing: Vec<&String> = pinned.keys().filter(|b| !seen.contains_key(b)).collect();
assert!(
missing.is_empty(),
"pinned bodies that no longer differ: {missing:#?}"
);
assert_eq!(
(t.bodies, t.identical, t.rounding_only),
(1332, 1256, 36),
"bodies, identical, within rounding"
);
// the largest rounding difference, relative (#3137: 4.885e-16)
assert!(t.rounding_largest <= 4.9e-16, "{:e}", t.rounding_largest);
}
}
/// The whole corpus (`PARITY_STRIDE` takes every nth entry; the run takes seconds in release).
#[test]
fn every_corpus_body_comes_back_as_the_flat_export_wrote_it() {
parity(
std::env::var("PARITY_STRIDE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1),
);
}
@@ -1,70 +0,0 @@
//! [`Order`]'s inverse and its pieces (#3137): what SolidCraft's `history::Order` gave, read off
//! the conversion's order. A body whose solids touch along an edge comes back with one record
//! edge per pair of faces that edge separates: each is a piece of that kernel edge.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test
use solidcore::boolean::api_brep::boolean_union;
use solidcore::kernel::Kernel;
use solidcore::math::Point3;
use solidcore::primitive::api_primitives::create_box;
use solidcore_convert::brep::to_record;
/// Two unit boxes touching along one edge (the line x = y = 1): one body, its shared edge one
/// kernel edge with four uses, two record edges.
#[test]
fn an_edge_solids_touch_along_is_two_pieces() {
let mut k = Kernel::default();
let a = create_box(&mut k, Point3::ZERO, 1.0, 1.0, 1.0).unwrap();
let c = create_box(&mut k, Point3::new(1.0, 1.0, 0.0), 1.0, 1.0, 1.0).unwrap();
let mut b = boolean_union(&mut k, a, c).unwrap();
let (record, order) = to_record(&mut k, &mut b).unwrap();
let pieces = order.pieces(&record).unwrap();
assert_eq!(pieces.len(), 2, "{pieces:?}");
assert_eq!(pieces[0].kernel, pieces[1].kernel);
assert_ne!(pieces[0].edge, pieces[1].edge);
for p in &pieces {
assert_eq!(p.faces.len(), 2, "{p:?}");
assert_eq!(order.edges[p.edge as usize], p.kernel);
// the faces are those of that record edge's own uses
let mut uses: Vec<u32> = record.edges[p.edge as usize]
.uses
.iter()
.map(|u| u.face)
.collect();
uses.sort_unstable();
uses.dedup();
assert_eq!(p.faces, uses, "{p:?}");
}
// the two pieces bound four different faces, two of each box
let mut all: Vec<u32> = pieces.iter().flat_map(|p| p.faces.clone()).collect();
all.sort_unstable();
all.dedup();
assert_eq!(all.len(), 4);
// the inverses name each entity's first record index
for (i, f) in order.faces.iter().enumerate() {
assert_eq!(order.record_faces()[f] as usize, i);
}
let first = order.record_edges()[&pieces[0].kernel];
assert_eq!(first, pieces[0].edge.min(pieces[1].edge));
for (i, v) in order.vertices.iter().enumerate() {
assert!(order.record_vertices()[v] as usize <= i);
}
}
/// A body with no touching edge has no pieces.
#[test]
fn a_box_has_no_pieces() {
let mut k = Kernel::default();
let mut b = create_box(&mut k, Point3::ZERO, 1.0, 1.0, 1.0).unwrap();
let (record, order) = to_record(&mut k, &mut b).unwrap();
assert!(order.pieces(&record).unwrap().is_empty());
// one record edge per kernel edge here; in general there are at most as many kernel edges
// as record edges (a touching edge is copied)
assert!(order.record_edges().len() <= record.edges.len());
assert_eq!(order.record_edges().len(), record.edges.len());
}
+1 -2
View File
@@ -5,8 +5,7 @@ edition.workspace = true
license.workspace = true
version.workspace = true
publish = false
# solidcore-record needs Rust 1.88 (edition 2024 let chains).
rust-version = "1.88"
rust-version.workspace = true
repository = "https://github.com/tangency-labs/solidcore"
keywords = ["cad", "brep", "nurbs", "modelling"]
categories = ["science"]
+7 -5
View File
@@ -824,11 +824,13 @@ fn overflow_first(
}
// a round mitred to one that goes first goes with it, through every mitre
let place = |e: &EdgeId| edges.iter().position(|p| p == e);
// two by two (an odd last one is no pair)
let (mitred_pairs, _) = mitred.as_chunks::<2>();
let pairs: Vec<(usize, usize)> = mitred_pairs
.iter()
.filter_map(|[a, b]| Some((place(a)?, place(b)?)))
let pairs: Vec<(usize, usize)> = mitred
// two by two (an odd last one is no pair); the chunk size 2 is not 0: cannot panic
.chunks_exact(2)
.filter_map(|pair| match pair {
[a, b] => Some((place(a)?, place(b)?)),
_ => None,
})
.collect();
let mut grew = true;
while grew {
-140
View File
@@ -1,140 +0,0 @@
//! Contact between bodies: whether two bodies meet (share material or touch within a
//! tolerance) and the distance between their boundaries, each decided by the kernel's
//! certified bracket ([`solidcore::query::contact`]). Ops passes the tolerance on as the
//! caller gives it and reads the kernel's verdict; it computes no distance of its own.
use solidcore::geom::certified::Verdict;
use solidcore::kernel::Kernel;
use solidcore::query::contact::{self as c, Contact, Prepared};
use solidcore::topology::brep::p3::brep_copy;
use solidcore::topology::Brep;
use solidcore::Point3;
use crate::body::Body;
use crate::session::{run_on, Answer};
use crate::{Error, Result};
impl Answer for Contact {}
impl Answer for Vec<Contact> {}
/// The bracket of the distance between two bodies' boundaries, at most the width asked.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Gap {
/// No point of either boundary is nearer the other than this.
pub lower: f64,
/// The distance of the witness points, rounded up.
pub upper: f64,
/// A point on each body `upper` apart, when one is known (the first on this body).
pub witness: Option<[[f64; 3]; 2]>,
}
fn array(p: Point3) -> [f64; 3] {
[p.x, p.y, p.z]
}
/// A copy of `body`'s kernel body, the body released at once: the contact test takes its
/// operands mutably (for its classifications' caches) and never edits the caller's, and a
/// body tested against itself is locked only once at a time.
fn copy_of(k: &mut Kernel, body: &Body) -> solidcore::Result<Brep> {
let b = body.lock();
brep_copy(k, &b)
}
/// The verdict as an answer: `Yes` meets, `No` does not, `Undecided` refused with the
/// bracket, which names the tolerance that would decide.
fn decided(contact: &Contact, other: usize) -> Result<bool> {
match contact.verdict {
Verdict::Yes => Ok(true),
Verdict::No => Ok(false),
Verdict::Undecided => Err(Error::ContactUndecided {
other,
lower: contact.lower,
upper: contact.upper,
}),
}
}
fn poisoned(bodies: &[&Body]) -> Result<()> {
if bodies.iter().any(|b| b.is_poisoned()) {
return Err(Error::Internal(
"contact: a body was left unfinished by an earlier panic".to_owned(),
));
}
Ok(())
}
impl Body {
/// Whether this body and `other` meet at `tol` (the caller's: ops invents none): share
/// material, or have boundaries within `tol` of each other
/// ([`solidcore::query::contact::meets`]). Neither body changes; `other` may be this body.
///
/// # Errors
/// [`Error::ContactUndecided`] when the kernel's bracket does not decide it (`other` 0):
/// the bracket, never read as either answer; [`Error::Status`] `SM_ERR_INVALID_INPUT` for a
/// tolerance not finite and positive or a body without faces; those of the session.
pub fn meets(&self, other: &Body, tol: f64) -> Result<bool> {
poisoned(&[self, other])?;
let contact = run_on("contact::meets", &[("a", self), ("b", other)], |k| {
let mut a = copy_of(k, self)?;
let mut b = copy_of(k, other)?;
c::meets(k, &mut a, &mut b, tol)
})?;
decided(&contact, 0)
}
/// [`Body::meets`] against each of `others`, each with its own tolerance, this body
/// prepared once (its faces' pieces about the centre of its box,
/// [`Prepared::about`]).
///
/// # Errors
/// [`Error::ContactUndecided`] for the first of `others` the kernel's bracket does not
/// decide, by its index; those of [`Body::meets`].
pub fn meets_each(&self, others: &[(&Body, f64)]) -> Result<Vec<bool>> {
let mut bodies: Vec<&Body> = vec![self];
bodies.extend(others.iter().map(|(b, _)| *b));
poisoned(&bodies)?;
let inputs: Vec<(&'static str, &Body)> = bodies.iter().map(|b| ("brep", *b)).collect();
let contacts = run_on("contact::meets_each", &inputs, |k| {
let mut a = copy_of(k, self)?;
let prepared = Prepared::about(k, &a)?;
let mut contacts = Vec::new();
for (other, tol) in others {
let mut b = copy_of(k, other)?;
contacts.push(c::meets_prepared(k, &mut a, &prepared, &mut b, *tol)?);
}
Ok(contacts)
})?;
contacts
.iter()
.enumerate()
.map(|(i, contact)| decided(contact, i))
.collect()
}
/// The distance between this body's boundary and `other`'s, bracketed to at most `width`
/// (relative to its upper end, absolute below 1;
/// [`solidcore::query::contact::boundary_distance`]).
///
/// # Errors
/// [`Error::ContactUndecided`] (`other` 0) with the narrowest bracket reached, when the
/// kernel's work runs out before `width`; [`Error::Status`] `SM_ERR_INVALID_INPUT` for a
/// width not finite and positive or a body without faces; those of the session.
pub fn boundary_distance(&self, other: &Body, width: f64) -> Result<Gap> {
poisoned(&[self, other])?;
let contact = run_on(
"contact::boundary_distance",
&[("a", self), ("b", other)],
|k| {
let mut a = copy_of(k, self)?;
let mut b = copy_of(k, other)?;
c::boundary_distance(k, &mut a, &mut b, width)
},
)?;
decided(&contact, 0)?;
Ok(Gap {
lower: contact.lower,
upper: contact.upper,
witness: contact.witness.map(|[p, q]| [array(p), array(q)]),
})
}
}
-21
View File
@@ -134,19 +134,6 @@ pub enum Error {
/// The kernel found its own state inconsistent part way through a call, or was called
/// before the preparation it needs.
Exception,
/// A contact test the kernel's certified bracket of the boundary distance does not decide
/// (`solidcore::query::contact`): the tolerance lies inside `[lower, upper]` (or the
/// boundaries are apart but one body's enclosure of the other is not decided), or a
/// distance's bracket is still wider than asked. Never read as either answer. `other` is
/// the index of the body tested among the call's others.
ContactUndecided {
/// The other body's index among the call's (0 for one).
other: usize,
/// No point of either boundary is nearer the other than this.
lower: f64,
/// A pair of points, one on each boundary, is this far apart.
upper: f64,
},
/// The application cancelled the call.
Cancelled,
/// Ops was called from inside a kernel call or from the failure callback.
@@ -338,14 +325,6 @@ impl std::fmt::Display for Error {
),
Error::TooFar { largest } => write!(f, "too far to extend (largest {largest:?})"),
Error::Exception => write!(f, "solidcore raised an internal exception"),
Error::ContactUndecided {
other,
lower,
upper,
} => write!(
f,
"contact with body {other} is undecided: the boundary distance is in [{lower}, {upper}]"
),
Error::Cancelled => write!(f, "cancelled"),
Error::Reentered => write!(
f,
+2 -9
View File
@@ -19,8 +19,8 @@
//! - [`provenance`]: the hook an application's construction history rides on.
//!
//! The families: [`measure`] (mass properties, area, box, point class, gap samples, the picks'
//! distances and normals), [`contact`] (whether bodies meet, their boundary distance),
//! [`boolean`], [`stitch`], [`prism`] (planar region booleans and extrusion, prism sections).
//! distances and normals), [`boolean`], [`stitch`], [`prism`] (planar region booleans and
//! extrusion, prism sections).
//!
//! Operations change the body they are given, as the kernel does. An application that keeps
//! its input copies it first ([`Body::copy`]).
@@ -32,7 +32,6 @@ pub mod blend;
pub mod body;
pub mod boolean;
pub mod check;
pub mod contact;
pub mod error;
pub mod extend;
pub mod file;
@@ -43,10 +42,8 @@ pub mod prism;
pub mod provenance;
pub mod session;
pub mod split;
pub mod step_assembly;
pub mod stitch;
pub mod sweep;
pub mod tangent;
pub mod untrim;
pub use blend::{
@@ -57,7 +54,6 @@ pub use blend::{
pub use body::{make_box, make_cone, make_cylinder, make_sphere, make_torus, Body};
pub use boolean::BooleanOp;
pub use check::{CheckLevel, CheckReport};
pub use contact::Gap;
pub use error::{Error, FitKind, Result, RoomReason, Unblendable};
pub use measure::{
point_distance, Distance, EdgePoint, FaceCentroid, GapSample, MassPropertiesEstimate, Normal,
@@ -65,10 +61,7 @@ pub use measure::{
};
pub use offset::{draft, offset_faces, offset_sheet, shell, thicken};
pub use order::Order;
pub use step_assembly::{placed_bodies, PlacedBody};
#[cfg(test)]
mod noisy_round_tests;
#[cfg(test)]
mod test_corpus;
#[cfg(test)]
-199
View File
@@ -1,199 +0,0 @@
//! #3066: a round on supports read from a record with rounding in their frames keeps its exact
//! definition. The class is decided within the body's tolerance; the round lies on a torus of
//! its own, its net certified there at bound 0, and that torus's distance from the class's
//! torus from the supports is held to the body's tolerance, so the round answers
//! `procedural_blend()`.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::disallowed_methods
)] // test
use std::f64::consts::PI;
use solidcore::surface::surface::SurfaceKind;
use solidcore::topology::face::p2 as face;
use solidcore_record::brep::Brep as Record;
use crate::blend::{blend, Blend, Corners};
use crate::boolean::boolean;
use crate::{make_box, make_cylinder, session, Body, BooleanOp, CheckLevel};
fn fixes() {
let fixes: Vec<String> = serde_json::from_str(include_str!(
"../../solidcore/tests/kernel_cases/fixes.json"
))
.unwrap();
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
}
/// The faces of `b` that are no plane, as (with a definition, without one).
fn definitions(b: &Body) -> (usize, usize) {
let brep = b.lock();
let (mut with, mut without) = (0, 0);
for &f in brep.face_list() {
let s = face::surface(&brep, f).unwrap().unwrap();
if matches!(s.kind(), SurfaceKind::Plane(_)) {
continue;
}
if s.procedural_blend().is_some() {
with += 1;
} else {
without += 1;
}
}
(with, without)
}
/// The edge of `b` through `p`.
fn edge_through(b: &Body, p: [f64; 3]) -> usize {
let k = solidcore::Kernel::default();
let brep = b.lock();
let q = solidcore::Vec3::new(p[0], p[1], p[2]);
brep.edge_list()
.iter()
.position(|&e| {
solidcore::query::api_queries::edge_closest_point(&k, &brep, e, q)
.unwrap()
.closest
.distance
< 1e-6
})
.unwrap()
}
/// A boss R 8 standing 10 out of a 40 x 40 x 10 block, its axis turned 1e-10 off square to the
/// block's top, its root rounded 2: a circle-class round on supports only that close to the
/// class (the error the 1e-10 turn makes over the part is far inside the body's tolerance, and
/// far outside the rounding the net is certified to). The round is concave: the corner between
/// the wall and the top, less the quarter disc, turned about the axis. By Pappus, it adds
/// 2 pi (R + x) r^2 (1 - pi / 4), x = r (10 - 3 pi) / (12 - 3 pi) the corner's centroid from the
/// wall.
#[test]
fn a_round_on_a_boss_tilted_off_the_class_keeps_its_definition() {
fixes();
let block = make_box([0.0; 3], [40.0, 40.0, 10.0]).unwrap();
// the boss from z = 5 to 20, its axis turned 1e-10 about x off square to the block's top
let boss = make_cylinder([0.0; 3], 8.0, 15.0).unwrap();
let (c, s) = (1e-10_f64.cos(), 1e-10_f64.sin());
boss.transform(&[
[1.0, 0.0, 0.0, 0.0],
[0.0, c, s, 0.0],
[0.0, -s, c, 0.0],
[20.0, 20.0, 5.0, 1.0],
])
.unwrap();
let b = boolean(block, boss, BooleanOp::Union).unwrap().unwrap();
let e = edge_through(&b, [28.0, 20.0, 10.0]);
let (r, big_r) = (2.0, 8.0);
blend(&b, Blend::Round, &[e], &[r], &[], Corners::Filled).unwrap();
let report = b.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "{}", report.text);
// the boss's wall and the round: the round keeps its definition
assert_eq!(definitions(&b), (1, 1));
let x = r * (10.0 - 3.0 * PI) / (12.0 - 3.0 * PI);
let want =
16000.0 + PI * big_r * big_r * 10.0 + 2.0 * PI * (big_r + x) * r * r * (1.0 - PI / 4.0);
let got = b.volume(1e-10).unwrap();
assert!((got - want).abs() <= 1e-9 * want, "{got} {want}");
}
/// SolidCraft's raked wedge (`tests/data/raked_wedge_noisy_cylinders.json`): its end outline's
/// rounds keep their definitions, the two circle-class rounds on the read-back cylinders among
/// them; the operand's own faces, read from the record, have none.
#[test]
fn the_raked_wedges_rounds_keep_their_definitions() {
fixes();
let record: Record = serde_json::from_str(include_str!(
"../tests/data/raked_wedge_noisy_cylinders.json"
))
.unwrap();
let (b, _) = Body::from_record(&record, true).unwrap();
let (_, before) = definitions(&b);
let top = 60.0 - 60.0 * 10f64.to_radians().tan();
let mut seeds = Vec::new();
for x in [-69.0, 69.0] {
for p in [
[x, -41.0, 22.0],
[x, 8.0, 41.0],
[x, (38.0 + top) / 2.0, 60.0],
] {
seeds.push(edge_through(&b, p));
}
}
let chain = crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap();
blend(&b, Blend::Round, &chain, &[0.854], &[], Corners::Filled).unwrap();
let report = b.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "{}", report.text);
let (with, without) = definitions(&b);
// as many of the new rounds keep their definitions as on the same wedge with exact
// cylinders, and the rest are as bare there
let (exact_with, exact_bare) = exact_wedge_rounds();
assert_eq!((with, without - before), (exact_with, exact_bare));
assert_eq!(with + without - before, chain.len());
}
/// The same wedge built here, its cylinders exact, its end outline rounded as SolidCraft's: of the
/// rounds that last call makes, how many keep a definition and how many don't.
fn exact_wedge_rounds() -> (usize, usize) {
use crate::sweep::{extrude, Curve, DraftCorner};
let top = 60.0 - 60.0 * 10f64.to_radians().tan();
let profile = [
[-60.0, 0.0],
[60.0, 0.0],
[top, 60.0],
[38.0, 60.0],
[-22.0, 22.0],
[-60.0, 22.0],
];
let at = |p: [f64; 2]| [-69.0, p[0], p[1]];
let curves: Vec<Curve> = (0..6)
.map(|i| Curve::Line {
start: at(profile[i]),
end: at(profile[(i + 1) % 6]),
})
.collect();
let b = extrude(
&curves,
[1.0, 0.0, 0.0],
138.0,
0.0,
DraftCorner::Curvature,
true,
)
.unwrap();
let round = |pts: &[[f64; 3]], r: f64, chain: bool| {
let seeds: Vec<usize> = pts.iter().map(|&p| edge_through(&b, p)).collect();
let edges = if chain {
crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap()
} else {
seeds
};
blend(&b, Blend::Round, &edges, &[r], &[], Corners::Filled).unwrap();
};
round(&[[0.0, -60.0, 22.0]], 1.2, true);
round(&[[0.0, -22.0, 22.0]], 1.5, true);
round(&[[0.0, 38.0, 60.0]], 1.5, true);
let back = (60.0 + top) / 2.0;
let four = [
[-69.0, -60.0, 12.0],
[69.0, -60.0, 12.0],
[-69.0, back, 30.0],
[69.0, back, 30.0],
];
round(&four, 4.0, false);
let (with_before, bare_before) = definitions(&b);
let mut ends = Vec::new();
for x in [-69.0, 69.0] {
ends.extend([
[x, -41.0, 22.0],
[x, 8.0, 41.0],
[x, (38.0 + top) / 2.0, 60.0],
]);
}
round(&ends, 0.854, true);
let (with, bare) = definitions(&b);
(with - with_before, bare - bare_before)
}
-467
View File
@@ -1,467 +0,0 @@
//! Compose a STEP product structure into placed body instances.
//!
//! The record reader keeps the placements exactly as the file writes them: assembly
//! occurrences, `MAPPED_ITEM` targets, representation maps and transformation operators.
//! This module turns those raw placements into kernel matrices, walks the product tree and
//! returns every body with the transform that places it in the file's world frame.
//!
//! It computes no geometry of its own: frame and operator matrices, their products and
//! inverses come from `solidcore::geom::step_matrix`. Body construction is the caller's:
//! each returned [`PlacedBody::entity`] is a STEP entity id and every occurrence of a shared
//! body is emitted separately with its own transform.
use std::collections::BTreeSet;
use solidcore::geom::step_matrix::{
concat, frame_matrix, inverse, operator_matrix, Matrix4, IDENTITY,
};
use solidcore_record::step::geom::Placement;
use solidcore_record::step::product::{
Frame, ItemKind, Link, Occurrence, ProductStructure, Transformation, MAX_ASSEMBLY_DEPTH,
MAX_VISITS,
};
/// One body as it appears in the expanded product structure.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlacedBody {
/// The STEP entity id of the body (`MANIFOLD_SOLID_BREP`, …).
pub entity: u64,
/// The body's placement in the file's world frame, in millimetres.
pub transform: Matrix4,
}
struct Walk<'a> {
structure: &'a ProductStructure,
/// Representations reachable from each definition's own shapes through plain
/// representation relationships.
closure: Vec<Vec<usize>>,
/// Child occurrences of each definition, in file order.
children: Vec<Vec<usize>>,
/// The definitions on the current path, to refuse a cycle.
path: Vec<usize>,
visits: usize,
out: Vec<PlacedBody>,
}
/// Expand the product structure and return every body with its world placement.
///
/// A root definition is placed by the identity. Each occurrence multiplies the child's
/// placement by the occurrence transformation, and each mapped item places the mapped
/// representation by `target · origin⁻¹`. Refuses cycles and past the record's depth or
/// instance bounds.
pub fn placed_bodies(structure: &ProductStructure) -> Result<Vec<PlacedBody>, String> {
let mut children = vec![Vec::new(); structure.definitions.len()];
for (index, occurrence) in structure.occurrences.iter().enumerate() {
if let Some(list) = children.get_mut(occurrence.parent) {
list.push(index);
}
}
let closure = (0..structure.definitions.len())
.map(|definition| representation_closure(structure, definition))
.collect();
let mut walk = Walk {
structure,
closure,
children,
path: Vec::new(),
visits: 0,
out: Vec::new(),
};
for &root in &structure.roots {
walk.definition(root, IDENTITY, 0)?;
}
Ok(walk.out)
}
fn representation_closure(structure: &ProductStructure, definition: usize) -> Vec<usize> {
let Some(definition) = structure.definitions.get(definition) else {
return Vec::new();
};
let mut seen = BTreeSet::new();
let mut out = Vec::new();
let mut stack = definition.shapes.clone();
while let Some(representation) = stack.pop() {
if !seen.insert(representation) {
continue;
}
out.push(representation);
for link in &structure.links {
if link.transformation.is_some() {
continue;
}
if link.rep_1 == representation {
stack.push(link.rep_2);
} else if link.rep_2 == representation {
stack.push(link.rep_1);
}
}
}
out
}
impl Walk<'_> {
fn definition(
&mut self,
definition: usize,
world: Matrix4,
depth: usize,
) -> Result<(), String> {
if depth > MAX_ASSEMBLY_DEPTH {
return Err("assembly nested too deeply".into());
}
if self.path.contains(&definition) {
return Err("assembly cycle".into());
}
self.visits += 1;
if self.visits > MAX_VISITS {
return Err(format!("more than {MAX_VISITS} assembly instances"));
}
self.path.push(definition);
let reps = self.closure.get(definition).cloned().unwrap_or_default();
let mut walked = BTreeSet::new();
for representation in reps {
self.representation(representation, world, &mut walked, depth)?;
}
let child_definitions: Vec<usize> =
self.children.get(definition).cloned().unwrap_or_default();
for occurrence_index in child_definitions {
let Some(occurrence) = self.structure.occurrences.get(occurrence_index) else {
continue;
};
let child_reps = self
.closure
.get(occurrence.child)
.cloned()
.unwrap_or_default();
let local = occurrence_matrix(self.structure, occurrence, &child_reps)?;
self.definition(occurrence.child, concat(&world, &local), depth + 1)?;
}
self.path.pop();
Ok(())
}
fn representation(
&mut self,
representation: usize,
world: Matrix4,
walked: &mut BTreeSet<usize>,
depth: usize,
) -> Result<(), String> {
if depth > MAX_ASSEMBLY_DEPTH {
return Err("mapped items nested too deeply".into());
}
if !walked.insert(representation) {
return Ok(());
}
let Some(rep) = self.structure.representations.get(representation) else {
return Err(format!("missing representation #{representation}"));
};
for item in &rep.items {
match &item.kind {
ItemKind::Body(_) => self.out.push(PlacedBody {
entity: item.entity,
transform: world,
}),
ItemKind::Mapped { map, target } => {
let Some(map) = self.structure.maps.get(*map) else {
return Err(format!("missing representation map #{}", item.entity));
};
let target = frame(target)?;
let origin = frame(&map.origin)?;
let origin_inverse = inverse(&origin)
.ok_or_else(|| format!("singular map origin #{}", map.entity))?;
let local = concat(&target, &origin_inverse);
let placed = concat(&world, &local);
let mapped = map.representation;
let mut mapped_walked = BTreeSet::new();
self.representation(mapped, placed, &mut mapped_walked, depth + 1)?;
for link in &self.structure.links {
if link.transformation.is_some() {
continue;
}
let other = if link.rep_1 == mapped {
Some(link.rep_2)
} else if link.rep_2 == mapped {
Some(link.rep_1)
} else {
None
};
if let Some(other) = other {
self.representation(other, placed, &mut mapped_walked, depth + 1)?;
}
}
}
ItemKind::Other(_) => {}
}
}
Ok(())
}
}
fn occurrence_matrix(
structure: &ProductStructure,
occurrence: &Occurrence,
child_reps: &[usize],
) -> Result<Matrix4, String> {
let Some(&link_index) = occurrence.placements.first() else {
return Ok(IDENTITY);
};
let Some(link) = structure.links.get(link_index) else {
return Err(format!("missing placement link #{link_index}"));
};
link_matrix(link, child_reps)
}
fn link_matrix(link: &Link, child_reps: &[usize]) -> Result<Matrix4, String> {
let Some(transformation) = &link.transformation else {
return Ok(IDENTITY);
};
match transformation {
Transformation::Frame(frame_value) => frame(frame_value),
Transformation::Items { item_1, item_2, .. } => {
let first = frame(item_1)?;
let second = frame(item_2)?;
let child_is_first =
child_reps.contains(&link.rep_1) || !child_reps.contains(&link.rep_2);
// Row-vector convention: a child in rep_1 is placed by
// `item_1⁻¹ · item_2`; a child in rep_2 by `item_2⁻¹ · item_1`.
let (from, to) = if child_is_first {
(&first, &second)
} else {
(&second, &first)
};
let inverse_from = inverse(from)
.ok_or_else(|| format!("singular occurrence placement #{}", link.entity))?;
Ok(concat(&inverse_from, to))
}
}
}
fn frame(value: &Frame) -> Result<Matrix4, String> {
match value {
Frame::Placement(Placement {
origin,
axis,
ref_direction,
}) => frame_matrix(*origin, *axis, *ref_direction)
.ok_or_else(|| "degenerate placement".to_string()),
Frame::Operator(operator) => operator_matrix(
operator.axis1,
operator.axis2,
operator.axis3,
operator.origin,
operator.scale,
operator.scale2,
operator.scale3,
)
.ok_or_else(|| "degenerate transformation operator".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcore_record::step::geom::Placement;
use solidcore_record::step::product::{
BodyKind, Definition, Item, OccurrenceKind, Representation,
};
use solidcore_record::step::units::{Context, Units};
fn translation(x: f64, y: f64, z: f64) -> Frame {
Frame::Placement(Placement {
origin: [x, y, z],
axis: None,
ref_direction: None,
})
}
fn body(entity: u64) -> Item {
Item {
entity,
name: String::new(),
kind: ItemKind::Body(BodyKind::ManifoldSolidBrep),
}
}
fn representation(entity: u64, items: Vec<Item>) -> Representation {
Representation {
entity,
kind: "SHAPE_REPRESENTATION".into(),
name: String::new(),
items,
context: None,
units: Context {
units: Units { len: 1.0, ang: 1.0 },
uncertainty: None,
},
}
}
fn definition(entity: u64, shape: usize) -> Definition {
Definition {
entity,
id: format!("def{entity}"),
description: String::new(),
formation: 0,
product: 0,
shapes: vec![shape],
}
}
fn structure() -> ProductStructure {
ProductStructure {
products: Vec::new(),
definitions: vec![definition(1, 0), definition(2, 1)],
representations: vec![
representation(10, Vec::new()),
representation(20, vec![body(200)]),
],
maps: Vec::new(),
links: Vec::new(),
occurrences: Vec::new(),
roots: vec![0],
styles: Vec::new(),
}
}
#[test]
fn a_placed_child_body_is_transformed_by_its_occurrence() {
let mut structure = structure();
structure.representations[0] = representation(10, Vec::new());
structure.occurrences.push(Occurrence {
entity: 30,
kind: OccurrenceKind::NextAssemblyUsage,
id: "occ".into(),
name: String::new(),
description: String::new(),
parent: 0,
child: 1,
reference_designator: None,
placements: vec![0],
});
structure.links.push(Link {
entity: 40,
rep_1: 1,
rep_2: 0,
transformation: Some(Transformation::Items {
entity: 41,
item_1: translation(0.0, 0.0, 0.0),
item_2: translation(2.0, 3.0, 0.0),
}),
});
let placed = placed_bodies(&structure).unwrap();
assert_eq!(placed.len(), 1);
assert_eq!(placed[0].entity, 200);
assert_eq!(
placed[0].transform,
[
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[2.0, 3.0, 0.0, 1.0],
]
);
}
#[test]
fn mapped_items_place_the_map_origin_onto_their_target() {
let mut structure = structure();
structure.representations[0] = representation(10, Vec::new());
structure.representations[0].items.push(Item {
entity: 100,
name: String::new(),
kind: ItemKind::Mapped {
map: 0,
target: translation(5.0, 0.0, 0.0),
},
});
structure
.maps
.push(solidcore_record::step::product::RepresentationMap {
entity: 50,
origin: translation(1.0, 0.0, 0.0),
representation: 1,
});
let placed = placed_bodies(&structure).unwrap();
assert_eq!(placed.len(), 1);
assert_eq!(placed[0].transform[3], [4.0, 0.0, 0.0, 1.0]);
}
#[test]
fn nested_occurrence_placements_compose() {
let mut structure = structure();
structure.definitions.push(definition(3, 2));
structure
.representations
.push(representation(30, vec![body(300)]));
structure.occurrences.extend([
Occurrence {
entity: 31,
kind: OccurrenceKind::NextAssemblyUsage,
id: "a".into(),
name: String::new(),
description: String::new(),
parent: 0,
child: 1,
reference_designator: None,
placements: vec![0],
},
Occurrence {
entity: 32,
kind: OccurrenceKind::NextAssemblyUsage,
id: "b".into(),
name: String::new(),
description: String::new(),
parent: 1,
child: 2,
reference_designator: None,
placements: vec![1],
},
]);
structure.links.extend([
Link {
entity: 40,
rep_1: 1,
rep_2: 0,
transformation: Some(Transformation::Items {
entity: 41,
item_1: translation(0.0, 0.0, 0.0),
item_2: translation(1.0, 0.0, 0.0),
}),
},
Link {
entity: 42,
rep_1: 2,
rep_2: 1,
transformation: Some(Transformation::Items {
entity: 43,
item_1: translation(0.0, 0.0, 0.0),
item_2: translation(0.0, 2.0, 0.0),
}),
},
]);
let placed = placed_bodies(&structure).unwrap();
let grandchild = placed
.iter()
.find(|body| body.entity == 300)
.expect("grandchild body");
assert_eq!(grandchild.transform[3], [1.0, 2.0, 0.0, 1.0]);
}
#[test]
fn a_cycle_is_refused() {
let mut structure = structure();
structure.occurrences.push(Occurrence {
entity: 30,
kind: OccurrenceKind::NextAssemblyUsage,
id: "occ".into(),
name: String::new(),
description: String::new(),
parent: 0,
child: 0,
reference_designator: None,
placements: Vec::new(),
});
assert!(placed_bodies(&structure).is_err());
}
}
-223
View File
@@ -1,223 +0,0 @@
//! Tangent chains on record order: the edges a pick runs on into tangentially, and the faces
//! joined to picked faces across smooth edges. The walks are here; every measure they stop
//! on is the kernel's: whether an edge's faces meet tangentially all along it
//! ([`solidcore::query::tangent_edge`]), how far one edge turns from running straight on into
//! another at a vertex and whether an edge is convex there ([`solidcore::query::edge_turn`]).
//!
//! The caller's `angle` (radians) is the one tolerance: edges meeting at less than it continue
//! one another, and faces meeting at less than it all along an edge make it smooth. Nothing
//! here picks one (SolidCraft passes its shaded view's half a degree).
//!
//! - [`tangent_chain_ordered`]: the picked edges, then every edge that continues one of them
//! within `angle` at a shared vertex, and so on. An edge does not join when it is not
//! between two faces (a one-face edge), when it is smooth (its faces meet tangentially all
//! along it, a seam among them), or when it turns over (convex where the edge it continues
//! is concave, or the reverse).
//! - [`tangent_face_chain_ordered`]: the picked faces, then every face across a smooth edge
//! from one of them, and so on.
//!
//! An edge whose smoothness the kernel leaves undecided (a blend's net within its margin of
//! the angle, [`solidcore::query::tangent_edge::Tangency::Undecided`]) joins nothing and is
//! named in the answer.
use solidcore::query::edge_turn::{convex_at, turn_at};
use solidcore::query::tangent_edge::{tangent_all_along, Tangency};
use solidcore::topology::edge;
use solidcore::{Brep, EdgeId, FaceId, Kernel};
use crate::session::with_body;
use crate::{Body, Error, Order, Result};
/// What [`tangent_chain_ordered`] and [`tangent_face_chain_ordered`] answer, in record
/// indices.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Chain {
/// The picks (each once, in order), then what joined them, in the order it joined.
pub members: Vec<usize>,
/// Edges the walk met whose smoothness the kernel left undecided: they joined nothing.
pub undecided: Vec<usize>,
}
/// A kernel edge's smoothness, decided once.
struct Smooth {
known: Vec<(EdgeId, Option<Tangency>)>,
}
impl Smooth {
fn of(
&mut self,
k: &Kernel,
b: &mut Brep,
e: EdgeId,
angle: f64,
) -> solidcore::Result<Option<Tangency>> {
if let Some((_, t)) = self.known.iter().find(|(f, _)| *f == e) {
return Ok(*t);
}
let t = tangent_all_along(k, b, e, angle)?;
self.known.push((e, t));
Ok(t)
}
}
/// An angle a chain takes: finite, strictly between 0 and a right angle.
fn angle_ok(angle: f64) -> bool {
angle > 0.0 && angle < std::f64::consts::FRAC_PI_2
}
fn index_of<T: PartialEq + Copy>(list: &[T], x: T) -> Option<usize> {
list.iter().position(|&y| y == x)
}
/// The tangent chain of `body`'s edges at record indices `edges` (through `order`, its
/// record's [`Order`]), as the module documentation describes, at the caller's `angle`;
/// `passed_over` names edges a pick passes over, which never join.
///
/// # Errors
/// [`Error::Argument`] for no edges, an angle not finite and strictly between 0 and a right
/// angle, or an index past the order or naming an edge the body does not have; the kernel's
/// refusals (a face with no NURBS form, an edge use with no pcurve, an edge too costly to
/// decide).
pub fn tangent_chain_ordered(
body: &Body,
order: &Order,
edges: &[usize],
passed_over: &[usize],
angle: f64,
) -> Result<Chain> {
if edges.is_empty() || !angle_ok(angle) {
return Err(Error::Argument);
}
let (copy, mapped) = body.copy_with_order(order)?;
with_body("tangent_chain_ordered", &copy, |k, b| {
let list = mapped.edges.clone();
let mut chain: Vec<EdgeId> = Vec::new();
for &i in edges {
match list.get(i) {
Some(&e) if b.edge_list().contains(&e) => {
if !chain.contains(&e) {
chain.push(e);
}
}
_ => return Ok(Err(Error::Argument)),
}
}
let skip: Vec<EdgeId> = passed_over
.iter()
.filter_map(|&i| list.get(i).copied())
.collect();
let mut smooth = Smooth { known: Vec::new() };
let mut undecided: Vec<EdgeId> = Vec::new();
let mut next = 0;
while let Some(&e) = chain.get(next) {
next += 1;
let ends = [edge::start_vertex(b, e)?, edge::end_vertex(b, e)?];
for v in ends.into_iter().flatten() {
for &f in &list {
if chain.contains(&f) || skip.contains(&f) {
continue;
}
let meets = [edge::start_vertex(b, f)?, edge::end_vertex(b, f)?];
if !meets.contains(&Some(v)) {
continue;
}
match smooth.of(k, b, f, angle)? {
None | Some(Tangency::Tangent) => continue,
Some(Tangency::Undecided { .. }) => {
if !undecided.contains(&f) {
undecided.push(f);
}
continue;
}
Some(Tangency::NotTangent) => {}
}
let turns_over = matches!(
(convex_at(k, b, e, v)?, convex_at(k, b, f, v)?),
(Some(a), Some(c)) if a != c
);
if turns_over {
continue;
}
if turn_at(k, b, e, f, v)?.is_some_and(|turn| turn < angle) {
chain.push(f);
}
}
}
}
let indices = |of: &[EdgeId]| -> Option<Vec<usize>> {
of.iter().map(|&e| index_of(&list, e)).collect()
};
Ok(match (indices(&chain), indices(&undecided)) {
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
_ => Err(Error::Exception),
})
})?
}
/// The faces of `body` at record indices `faces` (through `order`), then every face across a
/// smooth edge from one of them, and so on (module documentation), at the caller's `angle`.
///
/// # Errors
/// [`Error::Argument`] for no faces, an angle as for [`tangent_chain_ordered`], or an index
/// past the order or naming a face the body does not have; the kernel's refusals as for
/// [`tangent_chain_ordered`].
pub fn tangent_face_chain_ordered(
body: &Body,
order: &Order,
faces: &[usize],
angle: f64,
) -> Result<Chain> {
if faces.is_empty() || !angle_ok(angle) {
return Err(Error::Argument);
}
let (copy, mapped) = body.copy_with_order(order)?;
with_body("tangent_face_chain_ordered", &copy, |k, b| {
let face_list = mapped.faces.clone();
let edge_list = mapped.edges.clone();
let mut chain: Vec<FaceId> = Vec::new();
for &i in faces {
match face_list.get(i) {
Some(&f) if b.face_list().contains(&f) => {
if !chain.contains(&f) {
chain.push(f);
}
}
_ => return Ok(Err(Error::Argument)),
}
}
let mut smooth = Smooth { known: Vec::new() };
let mut undecided: Vec<EdgeId> = Vec::new();
let mut next = 0;
while let Some(&f) = chain.get(next) {
next += 1;
for &e in &edge_list {
let across = solidcore::query::edge::edge_faces(b, e)?;
if !across.contains(&f) {
continue;
}
match smooth.of(k, b, e, angle)? {
Some(Tangency::Tangent) => {}
Some(Tangency::Undecided { .. }) => {
if !undecided.contains(&e) {
undecided.push(e);
}
continue;
}
None | Some(Tangency::NotTangent) => continue,
}
for g in across {
if !chain.contains(&g) {
chain.push(g);
}
}
}
}
let members: Option<Vec<usize>> = chain.iter().map(|&f| index_of(&face_list, f)).collect();
let undecided: Option<Vec<usize>> =
undecided.iter().map(|&e| index_of(&edge_list, e)).collect();
Ok(match (members, undecided) {
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
_ => Err(Error::Exception),
})
})?
}
+3 -71
View File
@@ -1,12 +1,8 @@
//! The boolean family on bodies whose answers are known.
#![allow(clippy::unwrap_used, clippy::indexing_slicing)] // test
//! The boolean family on boxes whose answers are known.
#![allow(clippy::unwrap_used)]
use std::io::Read as _;
use serde_json::Value;
use solidcore_ops::boolean::{boolean, imprint, union_all};
use solidcore_ops::{make_box, session, Body, BooleanOp, CheckLevel, Error};
use solidcore_record::brep::Brep as Record;
use solidcore_ops::{make_box, session, Body, BooleanOp, Error};
fn all_fixes() {
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
@@ -80,67 +76,3 @@ fn an_imprint_cuts_the_faces_the_tool_crosses() {
assert_eq!(r.counts().unwrap(), [6, 12, 8]);
assert!(near(volume(&r), 1.0), "{}", volume(&r));
}
/// The kernel rig's case `booleans/<case>`: its entry, and its operand bodies by name, read
/// under the corpus's fix set.
fn corpus_case(case: &str) -> (Value, impl Fn(&str) -> Body) {
let corpus = format!(
"{}/../solidcore/tests/kernel_cases",
env!("CARGO_MANIFEST_DIR")
);
let fixes: Vec<String> =
serde_json::from_str(&std::fs::read_to_string(format!("{corpus}/fixes.json")).unwrap())
.unwrap();
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
let entry: Value = serde_json::from_str(
&std::fs::read_to_string(format!("{corpus}/booleans/{case}.json")).unwrap(),
)
.unwrap();
let gz = std::fs::File::open(format!("{corpus}/booleans/{case}.bodies.json.gz")).unwrap();
let mut text = String::new();
flate2::read::GzDecoder::new(gz)
.read_to_string(&mut text)
.unwrap();
let records: Value = serde_json::from_str(&text).unwrap();
let trusted = entry["bodies"].clone();
let body = move |name: &str| {
let record: Record = serde_json::from_value(records[name].clone()).unwrap();
Body::from_record(&record, trusted[name]["trusted"].as_bool().unwrap())
.unwrap()
.0
};
(entry, body)
}
/// A rod cut by two one-turn screw sweeps of one channel profile, one of each hand, from its
/// top face: the profile's radial walls meet the rod's cylinder, so each sweep's rail there is
/// a helix on it, and the two rails cross. The crossing is found more than once, a few 1e-12
/// apart in the rail's parameter; the new edge along the second rail took the interval its
/// UV curve's trim had snapped onto a knot of its own, 2.4e-12 before the 3D curve's end
/// (`SmEdge` test 2). Cut in turn and cut by the two sweeps' union, the result is one valid
/// solid of the case's closed form (its `expected.method`).
#[test]
fn a_rod_cut_by_two_counter_wound_channel_sweeps_is_valid() {
let (entry, body) = corpus_case("13462-rod-cut-by-two-counter-wound-channel-coils");
let want = entry["expected"]["volume_mm3"].as_f64().unwrap();
let rel = entry["expected"]["tolerance"]["rel"].as_f64().unwrap();
// b6 the rod, b4 and b5 the right- and left-handed sweeps, b14 the rod cut by b4 as the
// case's run read it back from its record
let in_turn = boolean(body("b14"), body("b5"), BooleanOp::Subtract)
.unwrap()
.unwrap();
let both = union_all(vec![body("b4"), body("b5")]).unwrap();
let at_once = boolean(body("b6"), both, BooleanOp::Subtract)
.unwrap()
.unwrap();
for (how, r) in [("in turn", &in_turn), ("by the union", &at_once)] {
let report = r.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "cut {how}: {}", report.text);
assert_eq!(r.solid_count().unwrap(), 1, "cut {how}");
let v = volume(r);
assert!(
(v - want).abs() <= rel * want,
"cut {how}: volume {v}, closed form {want}"
);
}
}
-95
View File
@@ -1,95 +0,0 @@
//! Contact between bodies on closed forms: meets, meets against several, the boundary
//! distance, and the refusals.
#![allow(clippy::unwrap_used)]
use solidcore_ops::session;
use solidcore_ops::{make_box, make_sphere, Error};
fn all_fixes() {
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
}
#[test]
fn boxes_sharing_a_face_meet() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let b = make_box([1.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
assert!(a.meets(&b, 1e-6).unwrap());
assert!(b.meets(&a, 1e-6).unwrap());
}
#[test]
fn boxes_one_apart_do_not_meet_and_their_distance_is_one() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
assert!(!a.meets(&b, 1e-3).unwrap());
let g = a.boundary_distance(&b, 1e-9).unwrap();
assert!(g.lower <= 1.0 && 1.0 <= g.upper, "{g:?}");
assert!(g.upper - g.lower <= 1e-9 * g.upper.max(1.0), "{g:?}");
let [p, q] = g.witness.unwrap();
assert!(
(p[0] - 1.0).abs() <= 1e-12 && (q[0] - 2.0).abs() <= 1e-12,
"{g:?}"
);
}
#[test]
fn a_body_meets_itself_and_one_it_encloses() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [3.0, 3.0, 3.0]).unwrap();
let inner = make_box([1.0, 1.0, 1.0], [1.0, 1.0, 1.0]).unwrap();
assert!(a.meets(&a, 1e-6).unwrap());
assert!(a.meets(&inner, 1e-6).unwrap());
assert!(inner.meets(&a, 1e-6).unwrap());
// neither body changes
assert_eq!(a.counts().unwrap(), [6, 12, 8]);
assert_eq!(inner.counts().unwrap(), [6, 12, 8]);
}
#[test]
fn meets_each_answers_each_with_its_own_tolerance() {
all_fixes();
let a = make_sphere([0.0, 0.0, 0.0], 1.0).unwrap();
let touching = make_box([1.0, -1.0, -1.0], [1.0, 2.0, 2.0]).unwrap();
let near = make_sphere([2.5, 0.0, 0.0], 1.0).unwrap(); // 0.5 apart
let far = make_box([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let got = a
.meets_each(&[(&touching, 1e-6), (&near, 0.1), (&near, 0.6), (&far, 1.0)])
.unwrap();
assert_eq!(got, vec![true, false, true, false]);
}
/// The refusal is the kernel's own bracket: a tolerance equal to a gap the kernel brackets
/// to its rounding is inside the bracket, and neither answer is given.
#[test]
fn a_tolerance_inside_the_bracket_is_refused_with_it() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let far = make_box([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
match a.meets(&b, 1.0) {
Err(Error::ContactUndecided {
other: 0,
lower,
upper,
}) => assert!(lower <= 1.0 && 1.0 <= upper, "[{lower}, {upper}]"),
r => panic!("{r:?}"),
}
match a.meets_each(&[(&far, 1e-3), (&b, 1.0)]) {
Err(Error::ContactUndecided { other: 1, .. }) => {}
r => panic!("{r:?}"),
}
}
#[test]
fn a_tolerance_or_width_not_finite_and_positive_is_refused() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let invalid = Error::Status(solidcore::error::SM_ERR_INVALID_INPUT);
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(a.meets(&b, bad).unwrap_err(), invalid, "{bad}");
assert_eq!(a.boundary_distance(&b, bad).unwrap_err(), invalid, "{bad}");
}
}
+23 -90
View File
@@ -32,9 +32,7 @@
//! - `KERNEL_CASES_TIMEOUT_S`: a case's time limit, 180 s by default; one past it is a hang, and
//! the cases after it are not run (it holds the process kernel);
//! - `KERNEL_CASES_REPORT`: a file to write the per-area table and the lists to;
//! - `KERNEL_CASES_REPORT_ONLY`: set, the test reports without failing;
//! - `KERNEL_CASES_SMB`: a directory to write each run case's bodies to as `<case>.smb`, for
//! its render (`solidcore-diag`): the final bodies, or the inputs of the refusal that ends it.
//! - `KERNEL_CASES_REPORT_ONLY`: set, the test reports without failing.
#![allow(
clippy::unwrap_used,
@@ -233,38 +231,31 @@ fn f64s<const N: usize>(v: &Value) -> Option<[f64; N]> {
(a.len() == N).then_some(out)
}
/// A pick's record indices, optionally bounded by its record order's length. Every pick's shape
/// and range is checked first, so bad data fails wherever it sits; only then is a traced miss
/// reported as unsupported (#2993).
/// A pick's record indices, optionally bounded by its record order's length.
fn picked(op: &str, selection: &Value, length: Option<usize>) -> Result<Vec<usize>, Stop> {
let bad = |what: &str| Stop::Fail(format!("{op}: parameter {what}"));
let picks = selection["picked"]
selection["picked"]
.as_array()
.ok_or_else(|| bad("picked"))?;
let mut indices = Vec::with_capacity(picks.len());
let mut missed = None;
for pick in picks {
if let Some(miss) = pick.get("miss") {
let why = miss.as_str().ok_or_else(|| bad("miss"))?;
if pick.get("index").is_some() {
return Err(bad("pick must hit or miss"));
.ok_or_else(|| bad("picked"))?
.iter()
.map(|pick| {
if let Some(miss) = pick.get("miss") {
let why = miss.as_str().ok_or_else(|| bad("miss"))?;
if pick.get("index").is_some() {
return Err(bad("pick must hit or miss"));
}
return Err(Stop::Unsupported(format!("{op}: pick missed: {why}")));
}
missed.get_or_insert(why);
continue;
}
let index = pick["index"]
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("index"))?;
if length.is_some_and(|length| index >= length) {
return Err(bad("pick outside record order"));
}
indices.push(index);
}
match missed {
Some(why) => Err(Stop::Unsupported(format!("{op}: pick missed: {why}"))),
None => Ok(indices),
}
let index = pick["index"]
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("index"))?;
if length.is_some_and(|length| index >= length) {
return Err(bad("pick outside record order"));
}
Ok(index)
})
.collect()
}
/// Parses typed record picks without reordering them: misses are unsupported; bad data fails.
@@ -1206,21 +1197,6 @@ fn grade_seams(result: &[Slot], expected: &Value) -> Result<(), Stop> {
Ok(())
}
/// With `KERNEL_CASES_SMB` set, writes `bodies` there as `<case>.smb`, `<case>-1.smb`, ...
fn write_smb(e: &Value, bodies: &[Slot]) {
let Some(dir) = std::env::var_os("KERNEL_CASES_SMB") else {
return;
};
let case = e["case"].as_str().unwrap();
for (i, s) in bodies.iter().enumerate() {
let name = match i {
0 => format!("{case}.smb"),
_ => format!("{case}-{i}.smb"),
};
std::fs::write(Path::new(&dir).join(name), s.body.to_smb(true).unwrap()).unwrap();
}
}
/// Runs a case's calls and grades the result against `rig` (or, for a hand-written case
/// without one, `expected`): `Ok(deviation)` for a match.
fn run_case(entry: &Path, e: &Value) -> Result<Option<f64>, Stop> {
@@ -1322,10 +1298,7 @@ fn run_case_with_records(
// A handled refusal is an attempt, not the case's outcome. Keep checking the
// later operations and final geometry; an unmarked refusal still ends the case.
(Err(_), Some(_)) if continued => {}
(Err(_), Some(_)) => {
write_smb(e, &ins);
return Ok(None);
}
(Err(_), Some(_)) => return Ok(None),
(Err(x), None) => return Err(Stop::Fail(format!("call {i} ({op}) refused: {x}"))),
(Ok(_), Some(want)) => {
return Err(Stop::Fail(format!(
@@ -1348,7 +1321,6 @@ fn run_case_with_records(
.ok_or_else(|| Stop::Fail(format!("no final body {n}")))?,
);
}
write_smb(e, &result);
let got = measure(&result).map_err(Stop::Fail)?;
if let Some(seams) = e["expected"].get("seams") {
grade_seams(&result, seams)?;
@@ -1450,16 +1422,7 @@ fn seam_gaps_are_limited_independently_of_stored_tolerances() {
let body = solidcore_ops::make_box([0.0; 3], [10.0; 3]).unwrap();
let (mut record, _) = body.to_record().unwrap();
// every stored tolerance inflated, not the body's alone: the import keeps each edge's and
// vertex's own and takes the body's as the least of them (#3187, as SolidCraft's flat
// import does), so a body tolerance alone no longer reaches the edges the grade reads
record.tolerance = 0.01;
for edge in &mut record.edges {
edge.tolerance = 0.01;
}
for vertex in &mut record.vertices {
vertex.tolerance = 0.01;
}
for face in &mut record.faces {
match &mut face.surface {
Surface::Plane { origin, .. } => {
@@ -3944,36 +3907,6 @@ fn record_picks_preserve_order_and_duplicate_parameter_alignment() {
));
}
/// Bad pick data fails wherever it sits in the list; a traced miss is unsupported only when
/// every other pick is well formed (#2993).
#[test]
fn a_traced_miss_is_unsupported_only_among_well_formed_picks() {
let list = |picked: Value| serde_json::json!({"kind": "edge", "picked": picked});
for bad in [
serde_json::json!([{"miss": "none"}, {"index": 999}]),
serde_json::json!([{"index": 999}, {"miss": "none"}]),
serde_json::json!([{"miss": "none"}, {}]),
serde_json::json!([{"miss": "none"}, {"miss": "none", "index": 0}]),
] {
assert!(
matches!(
record_picks("blend", &list(bad.clone()), "edge", 2),
Err(Stop::Fail(_))
),
"{bad}"
);
}
assert!(matches!(
record_picks(
"blend",
&list(serde_json::json!([{"index": 0}, {"miss": "none"}])),
"edge",
2
),
Err(Stop::Unsupported(_))
));
}
/// Captured split followed by merging its redundant faces, with the original grading.
#[test]
fn split_body_replays_face_plane() {
-235
View File
@@ -1,235 +0,0 @@
//! `tangent::tangent_chain_ordered` and `tangent_face_chain_ordered` (#3141), on record order.
//!
//! A stadium prism: two straight walls and two half-cylinders, 4 high. Its top outline is
//! four edges each running on tangentially into the next, so a pick of one top edge chains
//! all four and nothing else (the walls meet each other smoothly, so the vertical edges
//! between them are smooth, and the bottom outline turns square from the top's). Its walls
//! meet across smooth edges, so a pick of one wall chains the four walls and not the caps. A
//! box's edges and faces all meet square: a pick chains itself alone.
//!
//! Ported from SolidCraft (`crates/kernel/tests/chamfer_curved_holder.rs`,
//! `one_chamfer_pick_follows_the_tangent_chain_at_any_part_size`, its #2672): a plate of side
//! 140, 145 and 250, 10 thick, with a 50 x 10 stadium boss 10 tall on top. One pick on the
//! boss's top straight edge chains the boss's whole top rim, its four edges at z = 20, at
//! every plate size (SolidCraft's walk once stopped at the straight edge past a 200 mm part).
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
use solidcore_ops::boolean::{boolean, BooleanOp};
use solidcore_ops::sweep::{extrude, Curve, DraftCorner};
use solidcore_ops::tangent::{tangent_chain_ordered, tangent_face_chain_ordered};
use solidcore_ops::{make_box, Body};
use solidcore_record::brep::{Brep as Record, Surface};
use std::f64::consts::{FRAC_PI_2, PI};
const H: f64 = 4.0;
/// SolidCraft's smooth-edge angle, the caller's: half a degree.
const ANGLE: f64 = 0.5 * PI / 180.0;
fn stadium() -> Body {
let profile = [
Curve::Line {
start: [0.0, 0.0, 0.0],
end: [20.0, 0.0, 0.0],
},
Curve::Arc {
center: [20.0, 5.0, 0.0],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: 5.0,
start: -FRAC_PI_2,
end: FRAC_PI_2,
},
Curve::Line {
start: [20.0, 10.0, 0.0],
end: [0.0, 10.0, 0.0],
},
Curve::Arc {
center: [0.0, 5.0, 0.0],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: 5.0,
start: FRAC_PI_2,
end: 1.5 * PI,
},
];
extrude(
&profile,
[0.0, 0.0, 1.0],
H,
0.0,
DraftCorner::default(),
true,
)
.unwrap()
}
/// The record index of the edge between the vertices at `a` and `b`.
fn edge(r: &Record, a: [f64; 3], b: [f64; 3]) -> usize {
let near = |i: u32, p: [f64; 3]| {
let q = r.vertices[i as usize].point;
(0..3).all(|j| (q[j] - p[j]).abs() < 1e-9)
};
r.edges
.iter()
.position(|e| {
let [s, t] = e.vertices;
(near(s, a) && near(t, b)) || (near(s, b) && near(t, a))
})
.expect("an edge between the points")
}
/// Whether record edge `i` lies in the top, both its ends at z = H.
fn on_top(r: &Record, i: usize) -> bool {
r.edges[i]
.vertices
.iter()
.all(|&v| (r.vertices[v as usize].point[2] - H).abs() < 1e-9)
}
/// The record index of the planar face whose normal is along `n` (up to sign).
fn plane_along(r: &Record, n: [f64; 3]) -> Vec<usize> {
let cross = |a: [f64; 3], b: [f64; 3]| {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
};
let along = |m: [f64; 3]| {
let c = cross(m, n);
c.iter().all(|x| x.abs() < 1e-9) && m.iter().any(|x| x.abs() > 1e-9)
};
(0..r.faces.len())
.filter(|&i| match r.faces[i].surface {
Surface::Plane { u_axis, v_axis, .. } => along(cross(u_axis, v_axis)),
_ => false,
})
.collect()
}
#[test]
fn a_stadium_s_top_outline_is_one_chain() {
let b = stadium();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
assert_eq!(chain.members.len(), 4, "{chain:?}");
assert_eq!(chain.members[0], front);
assert!(chain.members.iter().all(|&e| on_top(&r, e)), "{chain:?}");
}
#[test]
fn a_passed_over_edge_stops_the_chain() {
let b = stadium();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
let back = edge(&r, [20.0, 10.0, H], [0.0, 10.0, H]);
let chain = tangent_chain_ordered(&b, &order, &[front], &[back], ANGLE).unwrap();
assert_eq!(chain.members.len(), 3, "{chain:?}");
assert!(!chain.members.contains(&back));
}
#[test]
fn a_box_edge_chains_alone() {
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [10.0, 0.0, H]);
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
assert_eq!(chain.members, vec![front]);
assert!(chain.undecided.is_empty());
}
#[test]
fn a_stadium_s_walls_are_one_face_chain() {
let b = stadium();
let (r, order) = b.to_record().unwrap();
let walls = plane_along(&r, [0.0, 1.0, 0.0]);
assert_eq!(walls.len(), 2);
let chain = tangent_face_chain_ordered(&b, &order, &walls[..1], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
assert_eq!(chain.members.len(), 4, "{chain:?}");
for &f in &chain.members {
let caps = plane_along(&r, [0.0, 0.0, 1.0]);
assert!(!caps.contains(&f), "a cap joined: {chain:?}");
}
assert!(walls.iter().all(|w| chain.members.contains(w)), "{chain:?}");
}
#[test]
fn a_box_face_chains_alone() {
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
let (r, order) = b.to_record().unwrap();
let top = plane_along(&r, [0.0, 0.0, 1.0]);
let chain = tangent_face_chain_ordered(&b, &order, &top[..1], ANGLE).unwrap();
assert_eq!(chain.members, vec![top[0]]);
}
/// The plate of side `plate` with the stadium boss on top.
fn plate_with_stadium(plate: f64) -> Body {
let base = make_box([-plate / 2.0, -plate / 2.0, 0.0], [plate, plate, 10.0]).unwrap();
let z = 10.0;
let profile = [
Curve::Line {
start: [-20.0, -5.0, z],
end: [20.0, -5.0, z],
},
Curve::Arc {
center: [20.0, 0.0, z],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: 5.0,
start: -FRAC_PI_2,
end: FRAC_PI_2,
},
Curve::Line {
start: [20.0, 5.0, z],
end: [-20.0, 5.0, z],
},
Curve::Arc {
center: [-20.0, 0.0, z],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: 5.0,
start: FRAC_PI_2,
end: 1.5 * PI,
},
];
let boss = extrude(
&profile,
[0.0, 0.0, 1.0],
10.0,
0.0,
DraftCorner::default(),
true,
)
.unwrap();
boolean(base, boss, BooleanOp::Union).unwrap().unwrap()
}
#[test]
fn one_pick_follows_the_boss_rim_at_any_part_size() {
for plate in [140.0, 145.0, 250.0] {
let b = plate_with_stadium(plate);
let (r, order) = b.to_record().unwrap();
let back = edge(&r, [20.0, 5.0, 20.0], [-20.0, 5.0, 20.0]);
let chain = tangent_chain_ordered(&b, &order, &[back], &[], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "plate {plate}: {chain:?}");
assert_eq!(chain.members.len(), 4, "plate {plate}: {chain:?}");
let at_top = |i: usize| {
r.edges[i]
.vertices
.iter()
.all(|&v| (r.vertices[v as usize].point[2] - 20.0).abs() < 1e-9)
};
assert!(
chain.members.iter().all(|&e| at_top(e)),
"plate {plate}: {chain:?}"
);
}
}
+1 -2
View File
@@ -2,8 +2,7 @@
//! and structural validation ([`brep`]), its history and metadata records, the native body
//! file ([`native`]), and the plain data of vectors ([`Vec2`], [`Vec3`]), sketch planes
//! ([`Plane`]) and planar profiles ([`Seg2`], [`Loop2`], [`Region2`]), and STEP's Part 21
//! exchange structure as parsed from a file with the units of its representation contexts and
//! its Part 42 curves and surfaces ([`step`]).
//! exchange structure as parsed from a file ([`step`]).
//!
//! The record computes no geometric quantity: no square roots, trigonometry, dot or cross
//! products, normalising or solves (clippy `disallowed-methods`, this crate's `clippy.toml`). It
+1 -7
View File
@@ -1,9 +1,3 @@
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]), the units
//! of its representation contexts ([`units`]), its Part 42 curves and surfaces ([`geom`])
//! as the entity reader ([`read`]) maps them, and its product structure ([`product`]). Building
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]). Building
//! bodies from it is `solidcore-ops`' job (docs/layers.md §2, "Importers are solidcore's").
pub mod geom;
pub mod p21;
pub mod product;
pub mod read;
pub mod units;
-444
View File
@@ -1,444 +0,0 @@
//! STEP geometry (ISO 10303-42) as plain data: the curves and surfaces a file defines, exactly
//! as it defines them, with lengths in millimetres and angles in radians. Nothing is
//! approximated, bounded, normalised or turned into another kind on the way in: a circle stays a
//! circle with its placement, an unbounded cylinder stays unbounded, a B-spline keeps its own
//! knots and form, and a direction keeps the ratios the file writes. Every curve and surface keeps
//! STEP's own parameterisation, so a 2D curve in a surface's parameter space means the same thing
//! here as in the file.
//!
//! Entities the file shares (one surface under many edges' pcurves) are shared here too, through
//! `Arc`, so reading is linear in the file however often an entity is referenced.
//!
//! The record computes no geometric quantity (docs/layers.md §1, §4): a direction is not made a
//! unit vector, a placement's reference direction is not made orthogonal to its axis (nor chosen
//! when the file omits it), its y axis is not formed, a vector's length is not taken, and nothing
//! is evaluated. Those are the kernel's, on its own types, when it builds from this data (#2578,
//! slice 4). The formulas on the variants below say what the data means, in ISO 10303-42's
//! terms; they are not computed here.
//!
//! Origin: SolidCraft's `crates/step/src/geom.rs` at `a5b71ca2` (MIT OR Apache-2.0).
//!
//! Deviations from SolidCraft: [`Placement`] keeps `axis` and `ref_direction` as the file gives
//! them (each optional, neither normalised; was: both unit and orthogonal, a missing reference
//! direction chosen perpendicular to the axis), and [`Axis1::direction`] likewise. A `VECTOR` is
//! a [`Vector`] of the written orientation, magnitude and unit factors (was: a unit direction
//! and the vector's length), in [`Curve::Line`] and [`Surface::Extrusion`]. Removed, to come back
//! as kernel primitives in slice 4: `Placement::y`, `Placement::at` and the vector helpers `add`,
//! `sub`, `scale`, `dot`, `cross`, `norm`. Doc comments on every public item and field.
use std::sync::Arc;
/// A point or vector: `[x, y, z]` (z = 0 in a 2D parameter space).
pub type P3 = [f64; 3];
/// `AXIS2_PLACEMENT_3D`, or `AXIS2_PLACEMENT_2D` (no `axis`): an origin and the directions of
/// its local Z and X axes, as the file writes them.
///
/// ISO 10303-42 makes the local axes from these: Z is `axis` (+Z when absent), X is
/// `ref_direction` made orthogonal to Z (a chosen perpendicular when absent), Y is Z × X. That
/// construction, with its checks (a zero or parallel reference direction), is the kernel's.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placement {
/// The location, in millimetres (or the parameter space's units).
pub origin: P3,
/// Local Z as the file gives it (not normalised); `None` when the file omits it and for an
/// `AXIS2_PLACEMENT_2D`, which ISO 10303-42 defines as +Z.
pub axis: Option<P3>,
/// Local X as the file gives it (not normalised, not made orthogonal to `axis`); `None` when
/// the file omits it.
pub ref_direction: Option<P3>,
}
/// `AXIS1_PLACEMENT`: an origin and a direction, as the file writes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Axis1 {
/// The location, in millimetres.
pub origin: P3,
/// The direction as the file gives it (not normalised); `None` when the file omits it, which
/// ISO 10303-42 defines as +Z.
pub direction: Option<P3>,
}
/// `VECTOR`: an orientation and a magnitude, as the file writes them, and the unit factors of
/// the space they are in.
///
/// In millimetres and radians, component `i` of the vector is
/// `scale[i] · magnitude · orientation[i] / |orientation|`. The record does not form it; the
/// kernel does (slice 4).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Vector {
/// The direction ratios as written (not normalised; never all zero).
pub orientation: P3,
/// The magnitude as written, in file units (positive).
pub magnitude: f64,
/// The unit factor of each component: the length unit in model space, the parameter's unit
/// (length, angle, or 1) in a surface's parameter space.
pub scale: P3,
}
/// A STEP LOGICAL.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Logical {
/// `.T.`
True,
/// `.F.`
False,
/// `.U.`
Unknown,
}
/// `b_spline_curve_form`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CurveForm {
/// `.POLYLINE_FORM.`
PolylineForm,
/// `.CIRCULAR_ARC.`
CircularArc,
/// `.ELLIPTIC_ARC.`
EllipticArc,
/// `.PARABOLIC_ARC.`
ParabolicArc,
/// `.HYPERBOLIC_ARC.`
HyperbolicArc,
/// `.UNSPECIFIED.`, or anything else.
Unspecified,
}
/// `b_spline_surface_form`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SurfaceForm {
/// `.PLANE_SURF.`
PlaneSurf,
/// `.CYLINDRICAL_SURF.`
CylindricalSurf,
/// `.CONICAL_SURF.`
ConicalSurf,
/// `.SPHERICAL_SURF.`
SphericalSurf,
/// `.TOROIDAL_SURF.`
ToroidalSurf,
/// `.SURF_OF_REVOLUTION.`
SurfOfRevolution,
/// `.RULED_SURF.`
RuledSurf,
/// `.GENERALISED_CONE.`
GeneralisedCone,
/// `.QUADRIC_SURF.`
QuadricSurf,
/// `.SURF_OF_LINEAR_EXTRUSION.`
SurfOfLinearExtrusion,
/// `.UNSPECIFIED.`, or anything else.
Unspecified,
}
/// `knot_type`. A B-spline given without knots (`BEZIER_CURVE`, `UNIFORM_CURVE`,
/// `QUASI_UNIFORM_CURVE` and their surfaces) has them laid out on reading, and keeps the
/// matching type here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KnotSpec {
/// `.UNIFORM_KNOTS.`
UniformKnots,
/// `.QUASI_UNIFORM_KNOTS.`
QuasiUniformKnots,
/// `.PIECEWISE_BEZIER_KNOTS.`
PiecewiseBezierKnots,
/// `.UNSPECIFIED.`, or anything else.
Unspecified,
}
/// A B-spline curve: distinct knots with their multiplicities, as STEP writes them.
#[derive(Clone, Debug, PartialEq)]
pub struct BSplineCurve {
/// The degree (1 to `read::MAX_DEGREE`).
pub degree: usize,
/// The control points.
pub points: Vec<P3>,
/// Present for a rational curve: one positive weight per point.
pub weights: Option<Vec<f64>>,
/// The distinct knots, increasing.
pub knots: Vec<f64>,
/// Each knot's multiplicity (1 to degree + 1); they add up to points + degree + 1.
pub multiplicities: Vec<usize>,
/// The curve form.
pub form: CurveForm,
/// `closed_curve`.
pub closed: Logical,
/// `self_intersect`.
pub self_intersect: Logical,
/// The knot type.
pub knot_spec: KnotSpec,
}
/// A B-spline surface; `points[i][j]` runs along u with `i`, along v with `j`.
#[derive(Clone, Debug, PartialEq)]
pub struct BSplineSurface {
/// The degree in u.
pub u_degree: usize,
/// The degree in v.
pub v_degree: usize,
/// The control net, rows along u, every row the same length.
pub points: Vec<Vec<P3>>,
/// Present for a rational surface: one positive weight per control point.
pub weights: Option<Vec<Vec<f64>>>,
/// The distinct u knots, increasing.
pub u_knots: Vec<f64>,
/// Each u knot's multiplicity.
pub u_multiplicities: Vec<usize>,
/// The distinct v knots, increasing.
pub v_knots: Vec<f64>,
/// Each v knot's multiplicity.
pub v_multiplicities: Vec<usize>,
/// The surface form.
pub form: SurfaceForm,
/// `u_closed`.
pub u_closed: Logical,
/// `v_closed`.
pub v_closed: Logical,
/// `self_intersect`.
pub self_intersect: Logical,
/// The knot type.
pub knot_spec: KnotSpec,
}
/// One end of a `TRIMMED_CURVE`: a point on the basis curve or its parameter (a trim lists one
/// or both).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Trim {
/// A `CARTESIAN_POINT`.
Point(P3),
/// A `PARAMETER_VALUE` (radians on a circle or an ellipse).
Parameter(f64),
}
/// `trimming_preference`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrimPreference {
/// `.CARTESIAN.`
Cartesian,
/// `.PARAMETER.`
Parameter,
/// `.UNSPECIFIED.`, or anything else.
Unspecified,
}
/// `preferred_surface_curve_representation`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PreferredRep {
/// `.CURVE_3D.`, or anything else.
Curve3d,
/// `.PCURVE_S1.`
PcurveS1,
/// `.PCURVE_S2.`
PcurveS2,
}
/// Which `SURFACE_CURVE` subtype.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SurfaceCurveKind {
/// `SURFACE_CURVE`.
Surface,
/// `SEAM_CURVE`.
Seam,
/// `INTERSECTION_CURVE`.
Intersection,
/// `BOUNDED_SURFACE_CURVE`.
BoundedSurface,
}
/// `PCURVE`: a 2D curve in the parameter space of `surface`. Its coordinates are the surface's
/// own parameters (lengths in mm, angles in radians).
#[derive(Clone, Debug, PartialEq)]
pub struct Pcurve {
/// The surface.
pub surface: Arc<Surface>,
/// A 2D curve: every point and placement has z = 0 and no placement has an axis.
pub curve: Arc<Curve>,
}
/// An item of a `SURFACE_CURVE`'s `associated_geometry`.
#[derive(Clone, Debug, PartialEq)]
pub enum Associated {
/// A `PCURVE`.
Pcurve(Pcurve),
/// A surface.
Surface(Arc<Surface>),
}
/// A STEP curve.
#[derive(Clone, Debug, PartialEq)]
pub enum Curve {
/// `LINE`: `origin + t·v`, with `v` the [`Vector`].
Line {
/// `pnt`.
origin: P3,
/// `dir`.
vector: Vector,
},
/// `CIRCLE`. Parameter: the angle from the placement's X axis.
Circle {
/// The placement.
position: Placement,
/// The radius (positive).
radius: f64,
},
/// `ELLIPSE`: `origin + a·cos t·X + b·sin t·Y`.
Ellipse {
/// The placement.
position: Placement,
/// `a`, along X (positive).
semi_axis_1: f64,
/// `b`, along Y (positive).
semi_axis_2: f64,
},
/// `HYPERBOLA`: `origin + a·cosh t·X + b·sinh t·Y`.
Hyperbola {
/// The placement.
position: Placement,
/// `a` (positive).
semi_axis: f64,
/// `b` (positive).
semi_imag_axis: f64,
},
/// `PARABOLA`: `origin + f·t²·X + 2f·t·Y`.
Parabola {
/// The placement.
position: Placement,
/// `f` (not zero).
focal_dist: f64,
},
/// `B_SPLINE_CURVE` and its subtypes.
BSpline(Box<BSplineCurve>),
/// `POLYLINE`: segment `i` (from 0) is the parameters `[i, i + 1]`.
Polyline(Vec<P3>),
/// `TRIMMED_CURVE`.
Trimmed {
/// `basis_curve`.
basis: Arc<Curve>,
/// `trim_1` (one or two items).
trim_1: Vec<Trim>,
/// `trim_2` (one or two items).
trim_2: Vec<Trim>,
/// `sense_agreement`.
sense: bool,
/// `master_representation`.
preference: TrimPreference,
},
/// `SURFACE_CURVE`, `SEAM_CURVE`, `INTERSECTION_CURVE`, `BOUNDED_SURFACE_CURVE`.
SurfaceCurve {
/// Which subtype.
kind: SurfaceCurveKind,
/// `curve_3d`.
curve_3d: Arc<Curve>,
/// `associated_geometry` (at most two items).
associated: Vec<Associated>,
/// `master_representation`.
master: PreferredRep,
},
/// `OFFSET_CURVE_3D`.
OffsetCurve3d {
/// `basis_curve`.
basis: Arc<Curve>,
/// `distance`, in millimetres.
distance: f64,
/// `self_intersect`.
self_intersect: Logical,
/// `ref_direction` as the file gives it (not normalised).
ref_direction: P3,
},
}
/// A STEP surface.
#[derive(Clone, Debug, PartialEq)]
pub enum Surface {
/// `PLANE`: `origin + u·X + v·Y`.
Plane {
/// The placement.
position: Placement,
},
/// `CYLINDRICAL_SURFACE`: `origin + r·(cos u·X + sin u·Y) + v·Z`.
Cylinder {
/// The placement.
position: Placement,
/// `r` (positive).
radius: f64,
},
/// `CONICAL_SURFACE`: `origin + (r + v·tan a)·(cos u·X + sin u·Y) + v·Z`.
Cone {
/// The placement.
position: Placement,
/// `r` (not negative).
radius: f64,
/// `a`, in radians, its size in (0, π/2).
semi_angle: f64,
},
/// `SPHERICAL_SURFACE`: `origin + r·cos v·(cos u·X + sin u·Y) + r·sin v·Z`.
Sphere {
/// The placement.
position: Placement,
/// `r` (positive).
radius: f64,
},
/// `TOROIDAL_SURFACE`: `origin + (R + r·cos v)·(cos u·X + sin u·Y) + r·sin v·Z`.
Torus {
/// The placement.
position: Placement,
/// `R` (may be negative: some writers use that for a spindle torus).
major_radius: f64,
/// `r` (positive).
minor_radius: f64,
},
/// `DEGENERATE_TOROIDAL_SURFACE`: a torus whose minor radius reaches the axis;
/// `select_outer` keeps the outer part.
DegenerateTorus {
/// The placement.
position: Placement,
/// `R`.
major_radius: f64,
/// `r` (positive).
minor_radius: f64,
/// `select_outer`.
select_outer: bool,
},
/// `B_SPLINE_SURFACE` and its subtypes.
BSpline(Box<BSplineSurface>),
/// `SURFACE_OF_REVOLUTION`: the profile at `v` turned by the angle `u` about the axis.
Revolution {
/// `swept_curve`.
profile: Arc<Curve>,
/// `axis_position`.
axis: Axis1,
},
/// `SURFACE_OF_LINEAR_EXTRUSION`: `profile(u) + v·w`, with `w` the [`Vector`].
Extrusion {
/// `swept_curve`.
profile: Arc<Curve>,
/// `extrusion_axis`.
vector: Vector,
},
/// `OFFSET_SURFACE`.
Offset {
/// `basis_surface`.
basis: Arc<Surface>,
/// `distance`, in millimetres.
distance: f64,
/// `self_intersect`.
self_intersect: Logical,
},
/// `RECTANGULAR_TRIMMED_SURFACE`, its bounds in the basis surface's parameter units.
RectangularTrimmed {
/// `basis_surface`.
basis: Arc<Surface>,
/// `u1`.
u1: f64,
/// `u2`.
u2: f64,
/// `v1`.
v1: f64,
/// `v2`.
v2: f64,
/// `usense`.
u_sense: bool,
/// `vsense`.
v_sense: bool,
},
}
File diff suppressed because it is too large Load Diff
@@ -1,713 +0,0 @@
//! The product structure, one case at a time: a part, an assembly with a shared sub-assembly,
//! mapped items, colours, names with escapes, and what is refused. Lengths are the file's
//! numbers times the context's unit, written as the same products, so every comparison is
//! exact.
//!
//! Origin: the data cases of SolidCraft's `crates/kernel/src/step_in/tests.rs` at `a5b71ca2`
//! (`hand_written_part_in_inches_with_colour`, `two_instance_assembly`,
//! `assembly_cycles_are_bounded`, `exponential_assemblies_are_bounded`), checking the data the
//! import builds from instead of the bodies it builds.
use std::fmt::Write as _;
use super::{
BodyKind, Colour, Definition, Frame, Instance, Item, ItemKind, Link, MAX_ASSEMBLY_DEPTH,
MAX_VISITS, Operator, Product, ProductStructure, Representation, RepresentationMap, Side,
StyledItem, SurfaceStyle, Transformation, read,
};
use crate::step::geom::Placement;
use crate::step::p21::{self, Exchange};
use crate::step::units::{Context, Units};
fn file(data: &str) -> Exchange {
let t = format!(
"ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
);
match p21::parse(&t) {
Ok(ex) if ex.duplicates.is_empty() => ex,
Ok(ex) => panic!("ids defined twice: {:?}\n{t}", ex.duplicates),
Err(e) => panic!("{e}\n{t}"),
}
}
fn structure(data: &str) -> ProductStructure {
read(&file(data)).unwrap_or_else(|e| panic!("{e}"))
}
fn refused(data: &str) -> String {
match read(&file(data)) {
Ok(s) => panic!("read: {s:?}"),
Err(e) => e,
}
}
/// The application contexts and a body: #8, #9, the solid #101 'Cube' (its shell #100 empty:
/// the product structure does not read topology).
const BASE: &str = "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\
#9=APPLICATION_CONTEXT('');#100=CLOSED_SHELL('',());#101=MANIFOLD_SOLID_BREP('Cube',#100);\n";
/// A geometric context `#id` in the length unit `unit` (#id+2), radians (#id+3) and an
/// uncertainty of 1e-6 in that unit (#id+1).
fn context(id: u64, unit: &str) -> String {
format!(
"#{id}=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{},#{}))REPRESENTATION_CONTEXT('',''));\n#{}=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{},'','');\n#{}={unit};\n#{}=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n",
id + 1,
id + 2,
id + 3,
id + 1,
id + 2,
id + 2,
id + 3
)
}
const MM: &str = "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))";
const CM: &str = "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))";
/// Millimetres per centimetre as `units` forms it (prefix times metre).
const CM_MM: f64 = 1e-2 * 1000.0;
/// A product `name` and its definition `#pd`: `PRODUCT` #pd+1, `PRODUCT_CONTEXT` #pd+2, formation
/// #pd+3, `PRODUCT_DEFINITION_SHAPE` #pd+4.
fn product(pd: u64, name: &str) -> String {
format!(
"#{}=PRODUCT('{name}','{name}','',(#{}));\n#{}=PRODUCT_CONTEXT('',#9,'mechanical');\n#{}=PRODUCT_DEFINITION_FORMATION('','',#{});\n#{pd}=PRODUCT_DEFINITION('design','',#{},#8);\n#{}=PRODUCT_DEFINITION_SHAPE('','',#{pd});\n",
pd + 1,
pd + 2,
pd + 2,
pd + 3,
pd + 1,
pd + 3,
pd + 4
)
}
fn mm(len: f64) -> Context {
Context {
units: Units { len, ang: 1.0 },
uncertainty: Some(1.0E-6 * len),
}
}
fn product_of(entity: u64, name: &str) -> Product {
Product {
entity,
id: name.into(),
name: name.into(),
description: String::new(),
}
}
fn placement(origin: [f64; 3], axis: Option<[f64; 3]>, ref_direction: Option<[f64; 3]>) -> Frame {
Frame::Placement(Placement {
origin,
axis,
ref_direction,
})
}
/// A part in inches with a colour: SolidCraft's `hand_written_part_in_inches_with_colour`. The
/// definition's shape is an empty representation linked to the one with the solid.
#[test]
fn a_part() {
let mut t = String::from(BASE);
t += &context(
20,
"(CONVERSION_BASED_UNIT('INCH',#30)LENGTH_UNIT()NAMED_UNIT(*));\n#30=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#31);\n#31=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
);
t += &product(50, "Widget");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('brep',(#101,#65),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#62);\n#62=SHAPE_REPRESENTATION('',(),#20);\n#63=SHAPE_REPRESENTATION_RELATIONSHIP('','',#62,#60);\n";
t += "#65=AXIS2_PLACEMENT_3D('',#66,$,$);\n#66=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += "#70=STYLED_ITEM('',(#71),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.BOTH.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('',1.,0.5,0.);\n";
let inch = Context {
units: Units {
len: 25.4 * 1.0,
ang: 1.0,
},
uncertainty: Some(1.0E-6 * (25.4 * 1.0)),
};
assert_eq!(
structure(&t),
ProductStructure {
products: vec![product_of(51, "Widget")],
definitions: vec![Definition {
entity: 50,
id: "design".into(),
description: String::new(),
formation: 53,
product: 0,
shapes: vec![1],
}],
representations: vec![
Representation {
entity: 60,
kind: "ADVANCED_BREP_SHAPE_REPRESENTATION".into(),
name: "brep".into(),
items: vec![
Item {
entity: 101,
name: "Cube".into(),
kind: ItemKind::Body(BodyKind::ManifoldSolidBrep),
},
Item {
entity: 65,
name: String::new(),
kind: ItemKind::Other("AXIS2_PLACEMENT_3D".into()),
},
],
context: Some(20),
units: inch,
},
Representation {
entity: 62,
kind: "SHAPE_REPRESENTATION".into(),
name: String::new(),
items: Vec::new(),
context: Some(20),
units: inch,
},
],
maps: Vec::new(),
links: vec![Link {
entity: 63,
rep_1: 1,
rep_2: 0,
transformation: None,
}],
occurrences: Vec::new(),
roots: vec![0],
styles: vec![StyledItem {
entity: 70,
name: String::new(),
item: 101,
overrides: None,
surface: vec![SurfaceStyle {
side: Side::Both,
fill: Some(Colour::Rgb {
name: String::new(),
rgb: [1.0, 0.5, 0.0],
}),
rendering: None,
transparency: None,
}],
curve: Vec::new(),
}],
}
);
}
/// A solid outside any product: its representation is still read, and nothing else.
#[test]
fn a_loose_solid() {
let mut t = String::from(BASE);
t += &context(20, MM);
t += "#60=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('',(#101),#20);\n";
let s = structure(&t);
assert_eq!(
(s.products.len(), s.definitions.len(), s.roots.len()),
(0, 0, 0)
);
assert_eq!(s.representations.len(), 1);
assert_eq!(s.representations[0].entity, 60);
assert_eq!(s.representations[0].units, mm(1e-3 * 1000.0));
}
/// The occurrence `#n` of `child` in `parent` placed by an `ITEM_DEFINED_TRANSFORMATION` of
/// `item_1` (in `rep_1`) onto `item_2` (in `rep_2`): NAUO #n, its PDS #n+1, the transformation
/// #n+2, the relationship #n+3, the CDSR #n+4.
fn occurrence(
n: u64,
name: &str,
parent: u64,
child: u64,
reps: (u64, u64),
items: (u64, u64),
) -> String {
let mut t = String::new();
let _ = write!(
t,
"#{n}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('{n}','{name}','',#{parent},#{child},$);\n\
#{}=PRODUCT_DEFINITION_SHAPE('','',#{n});\n\
#{}=ITEM_DEFINED_TRANSFORMATION('','',#{},#{});\n\
#{}=(REPRESENTATION_RELATIONSHIP('','',#{},#{})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{})SHAPE_REPRESENTATION_RELATIONSHIP());\n\
#{}=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{},#{});\n",
n + 1,
n + 2,
items.0,
items.1,
n + 3,
reps.0,
reps.1,
n + 2,
n + 4,
n + 3,
n + 1
);
t
}
/// Top uses Sub twice and Part once; Sub uses Part once. Sub's placements are in centimetres,
/// Top's and Part's in millimetres. Four occurrences, six instances; the transformation is the
/// two placements as written, each in its representation's unit.
#[test]
fn an_assembly_with_a_shared_sub_assembly() {
let mut t = String::from(BASE);
t += &context(20, CM);
t += &context(30, MM);
t += &product(50, "Top");
t += &product(70, "Sub");
t += &product(90, "Part");
// Top (mm): its own frame #161 and the places of its three components #163, #165, #167.
t += "#160=SHAPE_REPRESENTATION('Top',(#161,#163,#165,#167),#30);\n#162=SHAPE_DEFINITION_REPRESENTATION(#54,#160);\n";
t += "#161=AXIS2_PLACEMENT_3D('',#1610,$,$);\n#1610=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += "#163=AXIS2_PLACEMENT_3D('',#1630,#1631,#1632);\n#1630=CARTESIAN_POINT('',(100.,0.,0.));\n#1631=DIRECTION('',(0.,0.,1.));\n#1632=DIRECTION('',(0.,2.,0.));\n";
t += "#165=AXIS2_PLACEMENT_3D('',#1650,$,$);\n#1650=CARTESIAN_POINT('',(0.,100.,0.));\n";
t += "#167=AXIS2_PLACEMENT_3D('',#1670,$,$);\n#1670=CARTESIAN_POINT('',(0.,0.,100.));\n";
// Sub (cm): its frame #171 and the place of its part #173.
t += "#170=SHAPE_REPRESENTATION('Sub',(#171,#173),#20);\n#172=SHAPE_DEFINITION_REPRESENTATION(#74,#170);\n";
t += "#171=AXIS2_PLACEMENT_3D('',#1710,$,$);\n#1710=CARTESIAN_POINT('',(1.,2.,3.));\n";
t += "#173=AXIS2_PLACEMENT_3D('',#1730,$,$);\n#1730=CARTESIAN_POINT('',(5.,0.,0.));\n";
// Part (mm): the solid and its frame.
t += "#180=ADVANCED_BREP_SHAPE_REPRESENTATION('Part',(#101,#181),#30);\n#182=SHAPE_DEFINITION_REPRESENTATION(#94,#180);\n";
t += "#181=AXIS2_PLACEMENT_3D('',#1810,$,$);\n#1810=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += &occurrence(600, "Sub:1", 50, 70, (170, 160), (171, 163));
t += &occurrence(610, "Sub:2", 50, 70, (170, 160), (171, 165));
t += &occurrence(620, "Part:1", 70, 90, (180, 170), (181, 173));
t += &occurrence(630, "Part:2", 50, 90, (180, 160), (181, 167));
let s = structure(&t);
assert_eq!(
s.products,
[
product_of(51, "Top"),
product_of(71, "Sub"),
product_of(91, "Part")
]
);
// Representations in id order: #160 Top, #170 Sub, #180 Part.
let shapes: Vec<_> = s
.definitions
.iter()
.map(|d| (d.entity, d.product, d.shapes.clone()))
.collect();
assert_eq!(
shapes,
[(50, 0, vec![0]), (70, 1, vec![1]), (90, 2, vec![2])]
);
assert_eq!(s.roots, [0]);
let occ: Vec<_> = s
.occurrences
.iter()
.map(|o| {
(
o.entity,
o.id.as_str(),
o.name.as_str(),
o.parent,
o.child,
o.placements.clone(),
)
})
.collect();
assert_eq!(
occ,
[
(600, "600", "Sub:1", 0, 1, vec![0]),
(610, "610", "Sub:2", 0, 1, vec![1]),
(620, "620", "Part:1", 1, 2, vec![2]),
(630, "630", "Part:2", 0, 2, vec![3]),
]
);
assert_eq!(s.occurrences[0].reference_designator, None);
// Sub:1: Sub's frame (cm: 1, 2, 3 → 10, 20, 30 mm) onto Top's place for it, as written.
assert_eq!(
s.links[0],
Link {
entity: 603,
rep_1: 1,
rep_2: 0,
transformation: Some(Transformation::Items {
entity: 602,
item_1: placement([1.0 * CM_MM, 2.0 * CM_MM, 3.0 * CM_MM], None, None),
item_2: placement(
[100.0, 0.0, 0.0],
Some([0.0, 0.0, 1.0]),
Some([0.0, 2.0, 0.0])
),
}),
}
);
// Part:1: Part's frame (mm) onto Sub's place for it (cm: 5 → 50 mm).
assert_eq!(
s.links[2].transformation,
Some(Transformation::Items {
entity: 622,
item_1: placement([0.0; 3], None, None),
item_2: placement([5.0 * CM_MM, 0.0, 0.0], None, None),
})
);
// Expanded: Top; Sub:1 with its Part:1; Sub:2 with its Part:1; Part:2.
let inst = s.instances().unwrap();
assert_eq!(
inst,
[
Instance {
definition: 0,
occurrence: None,
parent: None
},
Instance {
definition: 1,
occurrence: Some(0),
parent: Some(0)
},
Instance {
definition: 2,
occurrence: Some(2),
parent: Some(1)
},
Instance {
definition: 1,
occurrence: Some(1),
parent: Some(0)
},
Instance {
definition: 2,
occurrence: Some(2),
parent: Some(3)
},
Instance {
definition: 2,
occurrence: Some(3),
parent: Some(0)
},
]
);
}
/// SolidCraft's `exponential_assemblies_are_bounded`: P0 uses P1 twice, P1 uses P2 twice, … 30
/// levels. Reading is linear (31 definitions, 60 occurrences); expanding stops at
/// [`MAX_VISITS`]. Three levels expand to 1 + 2 + 4 + 8 instances.
#[test]
fn a_sub_assembly_shared_at_every_level_is_read_once() {
let levels = |n: u64| {
let mut t = String::from(BASE);
for l in 0..=n {
t += &product(1000 + l * 10, &format!("P{l}"));
if l < n {
for k in 0..2 {
let _ = writeln!(
t,
"#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);",
2000 + l * 10 + k,
1000 + l * 10,
1000 + (l + 1) * 10
);
}
}
}
t
};
let s = structure(&levels(30));
assert_eq!(
(s.definitions.len(), s.occurrences.len(), s.roots.clone()),
(31, 60, vec![0])
);
assert_eq!(
s.instances(),
Err(format!("more than {MAX_VISITS} assembly instances"))
);
assert_eq!(structure(&levels(3)).instances().map(|i| i.len()), Ok(15));
}
/// A representation placed twice by `MAPPED_ITEM`s: once by a placement, once by a
/// non-uniform operator. The map's origin is in the mapped representation's unit (inches), each
/// target in the unit of the representation that lists it (millimetres).
#[test]
fn mapped_items() {
let mut t = String::from(BASE);
t += &context(20, MM);
t += &context(
30,
"(CONVERSION_BASED_UNIT('INCH',#40)LENGTH_UNIT()NAMED_UNIT(*));\n#40=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#41);\n#41=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
);
t += &product(50, "Holder");
t += "#60=SHAPE_REPRESENTATION('',(#110,#115),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
t += "#110=MAPPED_ITEM('first',#111,#112);\n#111=REPRESENTATION_MAP(#113,#120);\n";
t += "#112=AXIS2_PLACEMENT_3D('',#1120,#1121,$);\n#1120=CARTESIAN_POINT('',(5.,0.,0.));\n#1121=DIRECTION('',(0.,0.,-1.));\n";
t += "#113=AXIS2_PLACEMENT_3D('',#1130,$,$);\n#1130=CARTESIAN_POINT('',(1.,0.,0.));\n";
t += "#115=MAPPED_ITEM('second',#111,#116);\n";
t += "#116=CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#1161,$,#1160,2.,$,$,3.);\n#1160=CARTESIAN_POINT('',(0.,7.,0.));\n#1161=DIRECTION('',(0.,1.,0.));\n";
t += "#120=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101,#113),#30);\n";
let s = structure(&t);
assert_eq!(
s.representations[0].items,
[
Item {
entity: 110,
name: "first".into(),
kind: ItemKind::Mapped {
map: 0,
target: placement([5.0, 0.0, 0.0], Some([0.0, 0.0, -1.0]), None),
},
},
Item {
entity: 115,
name: "second".into(),
kind: ItemKind::Mapped {
map: 0,
target: Frame::Operator(Operator {
axis1: Some([0.0, 1.0, 0.0]),
axis2: None,
axis3: None,
origin: [0.0, 7.0, 0.0],
scale: Some(2.0),
scale2: None,
scale3: Some(3.0),
}),
},
},
]
);
assert_eq!(
s.maps,
[RepresentationMap {
entity: 111,
origin: placement([1.0 * (25.4 * 1.0), 0.0, 0.0], None, None),
representation: 1,
}]
);
assert_eq!(s.representations[1].entity, 120);
assert_eq!(
s.representations[1].items[0].kind,
ItemKind::Body(BodyKind::ManifoldSolidBrep)
);
}
/// Colours on a body and on a face: a fill colour, a rendering colour with a transparency, a
/// pre-defined colour, one ISO 10303-46 does not name, a curve style's colour, and an override.
#[test]
fn colours_of_bodies_and_faces() {
let mut t = String::from(BASE);
t += "#102=ADVANCED_FACE('',(),#103,.T.);\n#103=PLANE('',$);\n";
// The body: fill colour and the curve colour of its edges.
t += "#70=STYLED_ITEM('body',(#71,#78),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.POSITIVE.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('steel',0.2,0.25,1.5);\n";
t += "#78=PRESENTATION_STYLE_ASSIGNMENT((#79));\n#79=CURVE_STYLE('',#80,POSITIVE_LENGTH_MEASURE(0.1),#81);\n#80=DRAUGHTING_PRE_DEFINED_CURVE_FONT('continuous');\n#81=DRAUGHTING_PRE_DEFINED_COLOUR('black');\n";
// The face, over the body's style: rendered red, a quarter transparent, filled 'mauve'.
t += "#90=OVER_RIDING_STYLED_ITEM('face',(#91),#102,#70);\n#91=PRESENTATION_STYLE_ASSIGNMENT((#92));\n#92=SURFACE_STYLE_USAGE(.BOTH.,#93);\n#93=SURFACE_SIDE_STYLE('',(#94,#97));\n";
t += "#94=SURFACE_STYLE_RENDERING_WITH_PROPERTIES(.NORMAL_SHADING.,#95,(#96));\n#95=DRAUGHTING_PRE_DEFINED_COLOUR('red');\n#96=SURFACE_STYLE_TRANSPARENT(0.25);\n";
t += "#97=SURFACE_STYLE_FILL_AREA(#98);\n#98=FILL_AREA_STYLE('',(#99));\n#99=FILL_AREA_STYLE_COLOUR('',#89);\n#89=DRAUGHTING_PRE_DEFINED_COLOUR('mauve');\n";
let s = structure(&t);
assert_eq!(
s.styles,
[
StyledItem {
entity: 70,
name: "body".into(),
item: 101,
overrides: None,
surface: vec![SurfaceStyle {
side: Side::Positive,
fill: Some(Colour::Rgb {
name: "steel".into(),
rgb: [0.2, 0.25, 1.5],
}),
rendering: None,
transparency: None,
}],
curve: vec![Colour::PreDefined {
name: "black".into(),
rgb: Some([0.0, 0.0, 0.0]),
}],
},
StyledItem {
entity: 90,
name: "face".into(),
item: 102,
overrides: Some(70),
surface: vec![SurfaceStyle {
side: Side::Both,
fill: Some(Colour::PreDefined {
name: "mauve".into(),
rgb: None,
}),
rendering: Some(Colour::PreDefined {
name: "red".into(),
rgb: Some([1.0, 0.0, 0.0]),
}),
transparency: Some(0.25),
}],
curve: Vec::new(),
},
]
);
}
/// ISO 10303-46's eight pre-defined colours.
#[test]
fn pre_defined_colours() {
let names = [
"red", "green", "blue", "yellow", "magenta", "cyan", "black", "white",
];
let rgb: Vec<_> = names.iter().map(|n| super::pre_defined_rgb(n)).collect();
assert_eq!(
rgb,
[
Some([1.0, 0.0, 0.0]),
Some([0.0, 1.0, 0.0]),
Some([0.0, 0.0, 1.0]),
Some([1.0, 1.0, 0.0]),
Some([1.0, 0.0, 1.0]),
Some([0.0, 1.0, 1.0]),
Some([0.0, 0.0, 0.0]),
Some([1.0, 1.0, 1.0]),
]
);
assert_eq!(super::pre_defined_rgb("Red"), None);
}
/// Names keep their escapes decoded, and are not trimmed.
#[test]
fn names_with_escapes() {
let mut t = String::from(BASE);
t += "#51=PRODUCT('P-\\X2\\00E9\\X0\\',' B\\X\\E4r ''7'' ','\\X2\\5706\\X0\\',(#52));\n#52=PRODUCT_CONTEXT('',#9,'mechanical');\n#53=PRODUCT_DEFINITION_FORMATION('','',#51);\n#50=PRODUCT_DEFINITION('d\\\\1',$,#53,#8);\n";
t += &product(70, "Child");
t += "#600=NEXT_ASSEMBLY_USAGE_OCCURRENCE('NAUO1','Gr\\X\\FC\\X\\DFe:1','',#50,#70,'R\\X2\\00E9\\X0\\f');\n";
let s = structure(&t);
assert_eq!(
s.products[0],
Product {
entity: 51,
id: "P-\u{e9}".into(),
name: " B\u{e4}r '7' ".into(),
description: "\u{5706}".into(),
}
);
assert_eq!(
(
s.definitions[0].id.as_str(),
s.definitions[0].description.as_str()
),
("d\\1", "")
);
let o = &s.occurrences[0];
assert_eq!(
(
o.id.as_str(),
o.name.as_str(),
o.reference_designator.as_deref()
),
("NAUO1", "Gr\u{fc}\u{df}e:1", Some("R\u{e9}f"))
);
}
/// A chain of `n` + 1 definitions, each the only component of the one before.
fn chain(n: u64) -> String {
let mut t = String::from(BASE);
for l in 0..=n {
t += &product(1000 + l * 10, &format!("P{l}"));
if l < n {
let _ = writeln!(
t,
"#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);",
5000 + l,
1000 + l * 10,
1000 + (l + 1) * 10
);
}
}
t
}
#[test]
fn nesting_is_bounded() {
let depth = MAX_ASSEMBLY_DEPTH as u64;
let s = structure(&chain(depth));
assert_eq!(s.instances().map(|i| i.len()), Ok(MAX_ASSEMBLY_DEPTH + 1));
assert_eq!(refused(&chain(depth + 1)), "assembly nested too deeply");
}
/// What is refused, with its message.
#[test]
fn refusals_say_why() {
let two = || {
let mut t = String::from(BASE);
t += &product(50, "A");
t += &product(70, "B");
t
};
// A contains B contains A (SolidCraft's `assembly_cycles_are_bounded`).
let cycle = two()
+ "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','','',#70,#50,$);\n";
assert_eq!(refused(&cycle), "assembly cycle");
let itself = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#50,$);\n";
assert_eq!(refused(&itself), "assembly cycle");
let missing_child = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#999,$);\n";
assert_eq!(
refused(&missing_child),
"occurrence #90: #999: expected a PRODUCT_DEFINITION"
);
let not_a_definition = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#51,$);\n";
assert_eq!(
refused(&not_a_definition),
"occurrence #90: #51: expected a PRODUCT_DEFINITION"
);
let missing_product = String::from(BASE)
+ "#53=PRODUCT_DEFINITION_FORMATION('','',#51);\n#50=PRODUCT_DEFINITION('design','',#53,#8);\n";
assert_eq!(refused(&missing_product), "#51: expected a PRODUCT");
let missing_rep = two() + "#61=SHAPE_DEFINITION_REPRESENTATION(#54,#999);\n";
assert_eq!(refused(&missing_rep), "missing entity #999");
let not_a_rep = two() + "#61=SHAPE_DEFINITION_REPRESENTATION(#54,#101);\n";
assert_eq!(
refused(&not_a_rep),
"#101: expected a representation, found MANIFOLD_SOLID_BREP"
);
let missing_item = two()
+ "#60=SHAPE_REPRESENTATION('',(#101,#999),$);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
assert_eq!(refused(&missing_item), "missing entity #999");
let mapped = |rest: &str| {
two()
+ "#60=SHAPE_REPRESENTATION('',(#110),$);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n#110=MAPPED_ITEM('',#111,#112);\n#112=AXIS2_PLACEMENT_3D('',#113,$,$);\n#113=CARTESIAN_POINT('',(0.,0.,0.));\n"
+ rest
};
assert_eq!(
refused(&mapped("#111=REPRESENTATION_MAP(#112,#60);\n")),
"mapped item cycle"
);
assert_eq!(
refused(&mapped("#111=SHAPE_REPRESENTATION('',(),$);\n")),
"#110: mapping source #111 is a SHAPE_REPRESENTATION, not a REPRESENTATION_MAP"
);
assert_eq!(
refused(&mapped("#111=REPRESENTATION_MAP(#112,#999);\n")),
"missing entity #999"
);
assert_eq!(
refused(&mapped(
"#111=REPRESENTATION_MAP(#112,#120);\n#120=SHAPE_REPRESENTATION('',(#101),$);\n"
)
.replace(
"#110=MAPPED_ITEM('',#111,#112);",
"#110=MAPPED_ITEM('',#111,#114);#114=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,$,#113,0.,$);"
)),
"mapped item #110: operator scale: expected a number other than zero"
);
assert_eq!(
refused(&mapped(
"#111=REPRESENTATION_MAP(#113,#120);\n#120=SHAPE_REPRESENTATION('',(#101),$);\n"
)),
"representation map #111: #113: expected a placement, found CARTESIAN_POINT"
);
let styled = |rest: &str| two() + rest;
assert_eq!(
refused(&styled("#170=STYLED_ITEM('',(),#999);\n")),
"styled item #170: missing entity #999"
);
assert_eq!(
refused(&styled(
"#170=STYLED_ITEM('',(#171),#101);\n#171=PRESENTATION_STYLE_ASSIGNMENT((#172));\n#172=SURFACE_STYLE_USAGE(.BOTH.,#173);\n#173=SURFACE_SIDE_STYLE('',(#174));\n#174=SURFACE_STYLE_RENDERING(.NORMAL_SHADING.,#175);\n#175=COLOUR_RGB('',1.,$,0.);\n"
)),
"styled item #170: green: expected a number"
);
let placed = two()
+ "#60=SHAPE_REPRESENTATION('',(),$);\n#80=SHAPE_REPRESENTATION('',(),$);\n#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=PRODUCT_DEFINITION_SHAPE('','',#90);\n#93=(REPRESENTATION_RELATIONSHIP('','',#60,#80)REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#92)SHAPE_REPRESENTATION_RELATIONSHIP());\n#94=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#93,#91);\n";
assert_eq!(refused(&placed), "relationship #93: missing entity #92");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-466
View File
@@ -1,466 +0,0 @@
//! The units of a representation context (ISO 10303-41 measures and units, ISO 10303-43
//! representation contexts): `GLOBAL_UNIT_ASSIGNED_CONTEXT`'s length and plane angle units and
//! `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT`'s distance uncertainty, read from a parsed
//! [`Exchange`]. Missing or malformed units read as millimetres and radians; nothing here fails.
//!
//! A unit is a factor: millimetres per file length unit, radians per file plane angle unit. An
//! SI unit is its prefix times its base (`METRE` = 1000 mm, `RADIAN` = 1 rad); a
//! `CONVERSION_BASED_UNIT` is the value of its measure times the factor of the measure's own unit
//! (an inch is `LENGTH_MEASURE(25.4)` of millimetres, a degree `PLANE_ANGLE_MEASURE(0.01745…)`
//! of radians). Only multiplications: the factor of a degree is the number the file writes, not
//! a π computed here (the record computes no geometric quantity, docs/layers.md §4).
//!
//! Origin: SolidCraft's `crates/step/src/units.rs` and the `Units` type of
//! `crates/step/src/read.rs` at `a5b71ca2` (MIT OR Apache-2.0), carried over with their
//! behaviour unchanged.
//!
//! Deviations from SolidCraft: [`Units`] lives here (it was in `read.rs`, which is not carried
//! over yet). The nesting bound of a `CONVERSION_BASED_UNIT` chain is the named
//! `MAX_CONVERSION_DEPTH` (was the literal 4). Otherwise only formatting and doc comments on every
//! public item.
use super::p21::{Exchange, Param};
/// Most `CONVERSION_BASED_UNIT` links followed from one unit (a unit defined from a unit defined
/// from …); a deeper chain, or a cycle, reads as no unit.
const MAX_CONVERSION_DEPTH: usize = 4;
/// The units of a representation context.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Units {
/// Millimetres per file length unit.
pub len: f64,
/// Radians per file plane angle unit.
pub ang: f64,
}
impl Default for Units {
/// Millimetres and radians.
fn default() -> Self {
Units { len: 1.0, ang: 1.0 }
}
}
/// What a representation context says about its numbers.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Context {
/// The length and plane angle units (millimetres and radians where the context names none
/// that can be read).
pub units: Units,
/// The context's distance uncertainty in millimetres (the last `UNCERTAINTY_MEASURE_WITH_UNIT`
/// it lists), if any.
pub uncertainty: Option<f64>,
}
/// The factor of an SI prefix (`$` or an unknown prefix: 1).
fn si_prefix(p: Option<&Param>) -> f64 {
match p.and_then(Param::as_enum) {
Some("EXA") => 1e18,
Some("PETA") => 1e15,
Some("TERA") => 1e12,
Some("GIGA") => 1e9,
Some("MEGA") => 1e6,
Some("KILO") => 1e3,
Some("HECTO") => 1e2,
Some("DECA") => 1e1,
Some("DECI") => 1e-1,
Some("CENTI") => 1e-2,
Some("MILLI") => 1e-3,
Some("MICRO") => 1e-6,
Some("NANO") => 1e-9,
Some("PICO") => 1e-12,
Some("FEMTO") => 1e-15,
Some("ATTO") => 1e-18,
_ => 1.0,
}
}
/// What a unit measures.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UnitKind {
/// A `LENGTH_UNIT`.
Length,
/// A `PLANE_ANGLE_UNIT`.
Angle,
/// Any other unit (solid angle, mass, …).
Other,
}
/// A unit's kind and size: millimetres for lengths, radians for plane angles (1 for anything
/// else, or a unit that cannot be read). `None` when `#id` is not an entity, or is a
/// `CONVERSION_BASED_UNIT` whose measure cannot be read or is not finite and positive.
pub fn unit_factor(ex: &Exchange, id: u64) -> Option<(UnitKind, f64)> {
factor(ex, id, 0)
}
fn factor(ex: &Exchange, id: u64, depth: usize) -> Option<(UnitKind, f64)> {
if depth > MAX_CONVERSION_DEPTH {
return None;
}
let e = ex.get(id)?;
let kind = if e.has("LENGTH_UNIT") {
UnitKind::Length
} else if e.has("PLANE_ANGLE_UNIT") {
UnitKind::Angle
} else {
UnitKind::Other
};
if let Some(si) = e.record("SI_UNIT") {
let base = match si.get(1).and_then(Param::as_enum) {
Some("METRE") => 1000.0,
Some("RADIAN") => 1.0,
_ => return Some((kind, 1.0)),
};
return Some((kind, si_prefix(si.first()) * base));
}
if let Some(cb) = e.record("CONVERSION_BASED_UNIT") {
let m = ex.get(cb.get(1)?.as_ref_id()?)?;
let mp = m.params();
let v = mp.first()?.as_f64()?;
let (_, f) = factor(ex, mp.get(1)?.as_ref_id()?, depth + 1)?;
let x = v * f;
return (x.is_finite() && x > 0.0).then_some((kind, x));
}
Some((kind, 1.0))
}
/// The units and uncertainty of the representation context `#id` (the defaults when `#id` is
/// not an entity or lists no readable unit).
///
/// Each unit `GLOBAL_UNIT_ASSIGNED_CONTEXT` lists that reads as a finite, positive length or
/// plane angle factor sets that unit; a later one wins. Each `UNCERTAINTY_MEASURE_WITH_UNIT` that
/// `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT` lists sets the uncertainty to its value times the factor
/// of its unit (the context's length unit when that cannot be read), or clears it when the
/// product is not finite and positive; the last one wins.
pub fn context(ex: &Exchange, id: u64) -> Context {
let mut c = Context::default();
let Some(e) = ex.get(id) else { return c };
if let Some(units) = e
.record("GLOBAL_UNIT_ASSIGNED_CONTEXT")
.and_then(<[Param]>::first)
.and_then(Param::as_list)
{
for uid in units.iter().filter_map(Param::as_ref_id) {
match unit_factor(ex, uid) {
Some((UnitKind::Length, f)) if f.is_finite() && f > 0.0 => c.units.len = f,
Some((UnitKind::Angle, f)) if f.is_finite() && f > 0.0 => c.units.ang = f,
_ => {}
}
}
}
if let Some(unc) = e
.record("GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT")
.and_then(<[Param]>::first)
.and_then(Param::as_list)
{
for uid in unc.iter().filter_map(Param::as_ref_id) {
if let Some(m) = ex.get(uid)
&& let Some(v) = m.params().first().and_then(Param::as_f64)
{
let f = m
.params()
.get(1)
.and_then(Param::as_ref_id)
.and_then(|x| unit_factor(ex, x))
.map(|x| x.1);
c.uncertainty =
Some(v * f.unwrap_or(c.units.len)).filter(|t| t.is_finite() && *t > 0.0);
}
}
}
c
}
#[cfg(test)]
mod tests {
use super::*;
use crate::step::p21;
use std::f64::consts::PI;
use std::fmt::Write as _;
/// Millimetres, radians and steradians as SolidCraft's writer and its topology tests
/// (`crates/step/src/topology_tests.rs`, `file`) write them.
const MM: &str = "#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\
#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\
#3=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT());";
fn parse(data: &str) -> Exchange {
let t = format!(
"ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
);
match p21::parse(&t) {
Ok(ex) => ex,
Err(e) => panic!("{e}\n{t}"),
}
}
/// A context `#100` with the units `units` and the uncertainties `unc` (entity ids).
fn ctx(data: &str, units: &str, unc: &str) -> Context {
let c = format!(
"#100=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({unc}))\
GLOBAL_UNIT_ASSIGNED_CONTEXT(({units}))REPRESENTATION_CONTEXT('',''));"
);
context(&parse(&format!("{data}{c}")), 100)
}
#[test]
fn solidcraft_file_context() {
// SolidCraft's topology test file: mm, rad, sr and an uncertainty of 1e-7 mm.
let c = ctx(
&format!(
"{MM}#4=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#1,'distance_accuracy_value','');"
),
"#1,#2,#3",
"#4",
);
assert_eq!(c.units, Units { len: 1.0, ang: 1.0 });
// 1e-7 times MILLI (1e-3) times METRE (1000 mm).
assert_eq!(c.uncertainty, Some(1e-7 * (1e-3 * 1000.0)));
}
#[test]
fn si_prefixes() {
for (prefix, mm) in [
(".MILLI.", 1e-3 * 1000.0),
("$", 1000.0),
(".MICRO.", 1e-6 * 1000.0),
(".CENTI.", 1e-2 * 1000.0),
(".KILO.", 1e3 * 1000.0),
] {
let c = ctx(
&format!("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT({prefix},.METRE.));"),
"#1",
"",
);
assert_eq!(c.units.len, mm, "{prefix}");
assert_eq!(c.units.ang, 1.0, "{prefix}");
assert_eq!(c.uncertainty, None, "{prefix}");
}
// The products the table defines: a millimetre is exactly 1, a metre 1000.
assert_eq!(1e-3 * 1000.0, 1.0);
let ex = parse("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.));");
assert_eq!(unit_factor(&ex, 1), Some((UnitKind::Length, 1e-6 * 1000.0)));
// A milliradian.
let ex = parse("#1=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT(.MILLI.,.RADIAN.));");
assert_eq!(unit_factor(&ex, 1), Some((UnitKind::Angle, 1e-3)));
}
#[test]
fn inches_and_degrees() {
// An inch as 25.4 mm, a degree as π/180 rad (written as the shortest decimal of
// PI / 180, so it reads as exactly that double).
let data = format!(
"{MM}#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#1);\
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(#12));\
#12=DIMENSIONAL_EXPONENTS(1.,0.,0.,0.,0.,0.,0.);\
#13=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(0.017453292519943295),#2);\
#14=(CONVERSION_BASED_UNIT('DEGREE',#13)NAMED_UNIT(#15)PLANE_ANGLE_UNIT());\
#15=DIMENSIONAL_EXPONENTS(0.,0.,0.,0.,0.,0.,0.);"
);
let c = ctx(&data, "#11,#14,#3", "");
assert_eq!(
c.units,
Units {
len: 25.4,
ang: PI / 180.0
}
);
// An inch defined from metres: 0.0254 times 1000 mm.
let c = ctx(
"#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.));\
#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.0254),#1);\
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(*));",
"#11",
"",
);
assert_eq!(c.units.len, 0.0254 * 1000.0);
// A foot defined from the inch: a chain of two conversions, 12 times 25.4 mm.
let c = ctx(
&format!(
"{data}#20=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(12.),#11);\
#21=(CONVERSION_BASED_UNIT('FOOT',#20)LENGTH_UNIT()NAMED_UNIT(*));"
),
"#21",
"",
);
assert_eq!(c.units.len, 12.0 * 25.4);
}
#[test]
fn missing_or_broken_units_read_as_mm_and_rad() {
let mm_rad = Units { len: 1.0, ang: 1.0 };
// No such context, or a context without units.
assert_eq!(context(&parse(MM), 100), Context::default());
assert_eq!(context(&parse(MM), 1), Context::default());
assert_eq!(Context::default().units, mm_rad);
for (data, units) in [
// Dangling references.
("", "#7,#8"),
// An SI unit with an unknown base: factor 1.
(
"#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.FURLONG.));",
"#1",
),
// A conversion whose measure is missing, unreadable, zero, negative or infinite.
(
"#1=(CONVERSION_BASED_UNIT('INCH',#9)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
(
"#2=(NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)LENGTH_UNIT());\
#3=LENGTH_MEASURE_WITH_UNIT($,#2);\
#1=(CONVERSION_BASED_UNIT('INCH',#3)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
(
"#2=(NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)LENGTH_UNIT());\
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.),#2);\
#1=(CONVERSION_BASED_UNIT('INCH',#3)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
(
"#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\
#3=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(-1.),#2);\
#1=(CONVERSION_BASED_UNIT('DEGREE',#3)PLANE_ANGLE_UNIT()NAMED_UNIT(*));",
"#1",
),
(
"#2=(NAMED_UNIT(*)SI_UNIT(.EXA.,.METRE.)LENGTH_UNIT());\
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E300),#2);\
#1=(CONVERSION_BASED_UNIT('BIG',#3)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
// A conversion that refers to itself: the depth bound ends it.
(
"#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.),#1);\
#1=(CONVERSION_BASED_UNIT('LOOP',#3)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
// Units that are neither lengths nor plane angles.
(MM, "#3"),
// An empty list.
(MM, ""),
] {
let c = ctx(data, units, "");
assert_eq!(c.units, mm_rad, "{data} / {units}");
}
// A unit list that is not a list.
let ex = parse(&format!(
"{MM}#100=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNIT_ASSIGNED_CONTEXT(#1)REPRESENTATION_CONTEXT('',''));"
));
assert_eq!(context(&ex, 100), Context::default());
// A broken length unit leaves the readable angle unit in place.
let c = ctx(
"#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT(.MILLI.,.RADIAN.));\
#1=(CONVERSION_BASED_UNIT('INCH',#9)LENGTH_UNIT()NAMED_UNIT(*));",
"#1,#2",
"",
);
assert_eq!(
c.units,
Units {
len: 1.0,
ang: 1e-3
}
);
}
#[test]
fn conversion_depth_is_bounded() {
// #1 is millimetres; #2..=#7 each 2 of the one before, so #(1+k) is 2^k mm and
// MAX_CONVERSION_DEPTH links are followed from the unit asked for.
let mut data = String::from("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));");
for k in 2..=7 {
write!(
data,
"#{m}=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.),#{p});\
#{k}=(CONVERSION_BASED_UNIT('U',#{m})LENGTH_UNIT()NAMED_UNIT(*));",
m = 100 + k,
p = k - 1
)
.unwrap();
}
let ex = parse(&data);
assert_eq!(unit_factor(&ex, 5), Some((UnitKind::Length, 16.0)));
assert_eq!(unit_factor(&ex, 6), None);
}
#[test]
fn uncertainty_in_file_units() {
let inch = format!(
"{MM}#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#1);\
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(*));"
);
// 1e-4 inch is 1e-4 * 25.4 mm.
let c = ctx(
&format!("{inch}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#11,'d','');"),
"#11,#2",
"#20",
);
assert_eq!(c.units.len, 25.4);
assert_eq!(c.uncertainty, Some(1e-4 * 25.4));
// A measure in metres in an inch context: its own unit, 1e-6 * 1000 mm.
let c = ctx(
&format!(
"{inch}#12=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.));\
#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06),#12,'d','');"
),
"#11",
"#20",
);
assert_eq!(c.uncertainty, Some(1e-6 * 1000.0));
// A measure whose unit cannot be read is in the context's length unit.
let c = ctx(
&format!("{inch}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),$,'d','');"),
"#11",
"#20",
);
assert_eq!(c.uncertainty, Some(1e-4 * 25.4));
// The last one listed wins.
let c = ctx(
&format!(
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.E-04),#1,'d','');"
),
"#1",
"#20,#21",
);
assert_eq!(c.uncertainty, Some(2e-4 * (1e-3 * 1000.0)));
}
#[test]
fn uncertainty_zero_negative_or_infinite_is_none() {
for v in ["0.", "-1.E-04", "1.E300"] {
// In exametres (1e21 mm): 0, negative, and 1e300 * 1e21 mm, which is infinite.
let c = ctx(
&format!(
"{MM}#12=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.EXA.,.METRE.));\
#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({v}),#12,'d','');"
),
"#1",
"#20",
);
assert_eq!(c.uncertainty, None, "{v}");
}
// A bad one after a good one clears it (the last one wins).
let c = ctx(
&format!(
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.),#1,'d','');"
),
"#1",
"#20,#21",
);
assert_eq!(c.uncertainty, None);
// One without a readable value, or a dangling reference, is skipped.
let c = ctx(
&format!(
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
#21=UNCERTAINTY_MEASURE_WITH_UNIT($,#1,'d','');"
),
"#1",
"#20,#21,#99",
);
assert_eq!(c.uncertainty, Some(1e-4 * (1e-3 * 1000.0)));
}
}
+1 -644
View File
@@ -1,5 +1,4 @@
//! The Part 21 parser over a STEP corpus: every file parses, the units of its representation
//! contexts, and its Part 42 curves and surfaces.
//! The Part 21 parser over a STEP corpus: every file parses.
//!
//! Ignored by default; the corpus is not in the repository. Run it with the directory of
//! `.stp` / `.step` files (read in place, recursively) in `SOLIDCORE_STEP_CORPUS`:
@@ -14,31 +13,6 @@
//! caller holding the file's bytes would. The test fails when any file does not parse. The
//! STEP files committed to the repository are parsed by `repository_files_parse`, which always
//! runs.
//!
//! `corpus_units` (ignored too, same variable and command with `corpus_units` as the filter)
//! reads every geometric representation context with assigned units in each file and prints
//! one line per file and a table of how often each length and plane angle unit occurs, and
//! how many contexts state an uncertainty. It fails when a file does not parse. Files with no
//! context whose length unit can be read are listed and counted, not failed: a file written
//! with Part 21 short names (`SUNT`, `LNGUNT`, `GMRPCN`, …) or one without geometry has none.
//!
//! `corpus_geometry` (ignored too, same variable, `corpus_geometry` as the filter) reads every
//! curve and surface entity of each file with `step::read::Reader`, in the units of the file's
//! first geometric context: curves in model space with `curve`, the curves of a parameter space
//! through their `PCURVE` with `pcurve`, surfaces with `surface`. It prints one line per file
//! and, per group and entity kind, how many were read and how many refused, with the reasons.
//! It also lists the entities where a check the record no longer makes (a direction shorter than
//! 1e-12, unit factors equal within 1e-12) would have decided otherwise. It fails when a file
//! does not parse; refusals are counted, not failed.
//!
//! `corpus_product` (ignored too, same variable, `corpus_product` as the filter) reads the
//! product structure of each file with `step::product::read` and expands its assembly tree. It
//! prints one line per file and the files with assemblies, and checks the structure against the
//! file's own entity counts: products, definitions, occurrences, body items (every solid the
//! file has is an item of a representation the structure holds), relationships with a
//! transformation and styled items must all be there; a mapped item it does not hold must be
//! listed only by representations it does not reach (counted by the kind that lists them). It
//! fails when a file does not parse, is refused, or a count differs.
#![allow(
clippy::unwrap_used,
@@ -47,11 +21,9 @@
clippy::indexing_slicing
)] // test
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use solidcore_record::step::p21;
use solidcore_record::step::units::{self, UnitKind};
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -137,116 +109,6 @@ fn corpus_parses() {
);
}
/// A length factor's name (millimetres per unit).
fn length_name(f: f64) -> String {
match f {
1.0 => "mm".into(),
25.4 => "inch".into(),
1000.0 => "m".into(),
10.0 => "cm".into(),
304.8 => "foot".into(),
_ => format!("{f} mm"),
}
}
/// A plane angle factor's name (radians per unit). A degree is the number the file writes:
/// `degree` when it is π/180 to 12 digits or more, else the written factor, so a file that
/// rounds π/180 shorter (`0.0174532925`) shows in the table as what it is.
fn angle_name(f: f64) -> String {
if f == 1.0 {
"rad".into()
} else if (f - std::f64::consts::PI / 180.0).abs() <= 1e-14 {
"degree".into()
} else {
format!("{f} rad")
}
}
#[test]
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
fn corpus_units() {
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
return;
};
let root = Path::new(&dir);
let mut paths = Vec::new();
files(root, &mut paths);
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
let mut failures = Vec::new();
let mut unread = Vec::new();
let mut lengths: BTreeMap<String, usize> = BTreeMap::new();
let mut angles: BTreeMap<String, usize> = BTreeMap::new();
let (mut contexts, mut with_uncertainty, mut files_read) = (0usize, 0usize, 0usize);
for path in &paths {
let name = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string();
let ex = match std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
{
Ok(ex) => ex,
Err(e) => {
println!("{name}: ERROR {e}");
failures.push(format!("{name}: {e}"));
continue;
}
};
let mut here = Vec::new();
let mut readable = false;
for (&id, e) in &ex.entities {
if !(e.has("GEOMETRIC_REPRESENTATION_CONTEXT") && e.has("GLOBAL_UNIT_ASSIGNED_CONTEXT"))
{
continue;
}
// Whether the context names a length unit that reads as a factor, rather than
// falling back to millimetres.
readable |= e
.record("GLOBAL_UNIT_ASSIGNED_CONTEXT")
.and_then(<[p21::Param]>::first)
.and_then(p21::Param::as_list)
.is_some_and(|l| {
l.iter().filter_map(p21::Param::as_ref_id).any(|u| {
matches!(units::unit_factor(&ex, u),
Some((UnitKind::Length, f)) if f.is_finite() && f > 0.0)
})
});
let c = units::context(&ex, id);
contexts += 1;
with_uncertainty += usize::from(c.uncertainty.is_some());
let (l, a) = (length_name(c.units.len), angle_name(c.units.ang));
*lengths.entry(l.clone()).or_default() += 1;
*angles.entry(a.clone()).or_default() += 1;
here.push(format!("#{id} {l}/{a} uncertainty {:?}", c.uncertainty));
}
println!("{name}: {} contexts: {}", here.len(), here.join(", "));
if readable {
files_read += 1;
} else {
println!("{name}: no context with a readable length unit");
unread.push(name);
}
}
println!(
"{files_read} of {} files have a context with a readable length unit; {contexts} contexts, {with_uncertainty} with an uncertainty",
paths.len()
);
println!("length units (contexts): {lengths:?}");
println!("plane angle units (contexts): {angles:?}");
println!(
"no context with a readable length unit: {} files {unread:?}",
unread.len()
);
assert!(
failures.is_empty(),
"files that did not parse:\n{}",
failures.join("\n")
);
}
/// The STEP files in the repository parse to every instance they hold, each id once: a parser
/// that dropped or merged instances would change the counts (the instance lines in the files).
#[test]
@@ -268,508 +130,3 @@ fn repository_files_parse() {
assert!(ex.duplicates.is_empty(), "{path}: {:?}", ex.duplicates);
}
}
/// Curve entity names (simple instances) the corpus count looks at: every one the reader maps,
/// and the other ISO 10303-42 curves a file may hold, which it refuses.
const CURVES: &[&str] = &[
"B_SPLINE_CURVE",
"B_SPLINE_CURVE_WITH_KNOTS",
"BEZIER_CURVE",
"BOUNDED_SURFACE_CURVE",
"CIRCLE",
"CLOTHOID",
"COMPOSITE_CURVE",
"COMPOSITE_CURVE_ON_SURFACE",
"CURVE_REPLICA",
"ELLIPSE",
"HYPERBOLA",
"INTERSECTION_CURVE",
"LINE",
"OFFSET_CURVE_2D",
"OFFSET_CURVE_3D",
"PARABOLA",
"POLYLINE",
"QUASI_UNIFORM_CURVE",
"SEAM_CURVE",
"SURFACE_CURVE",
"TRIMMED_CURVE",
"UNIFORM_CURVE",
];
/// Surface entity names, likewise.
const SURFACES: &[&str] = &[
"B_SPLINE_SURFACE",
"B_SPLINE_SURFACE_WITH_KNOTS",
"BEZIER_SURFACE",
"CONICAL_SURFACE",
"CURVE_BOUNDED_SURFACE",
"CYLINDRICAL_SURFACE",
"DEGENERATE_TOROIDAL_SURFACE",
"OFFSET_SURFACE",
"ORIENTED_SURFACE",
"PLANE",
"QUASI_UNIFORM_SURFACE",
"RECTANGULAR_COMPOSITE_SURFACE",
"RECTANGULAR_TRIMMED_SURFACE",
"SPHERICAL_SURFACE",
"SURFACE_OF_LINEAR_EXTRUSION",
"SURFACE_OF_REVOLUTION",
"SURFACE_REPLICA",
"TOROIDAL_SURFACE",
"UNIFORM_SURFACE",
];
/// Whether `e` is a curve or a surface for the count, and its kind: the entity name, or for a
/// complex instance `(B_SPLINE_CURVE …)` with `rational` when it is.
fn geometry_kind(e: &p21::Entity) -> Option<(bool, String)> {
if e.is_complex() {
let rational = if e.has("RATIONAL_B_SPLINE_CURVE") || e.has("RATIONAL_B_SPLINE_SURFACE") {
" rational"
} else {
""
};
let is_curve =
e.has("B_SPLINE_CURVE") || e.records.iter().any(|r| CURVES.contains(&r.name.as_str()));
let is_surface = e.has("B_SPLINE_SURFACE")
|| e.records
.iter()
.any(|r| SURFACES.contains(&r.name.as_str()));
let names: Vec<&str> = e
.records
.iter()
.map(|r| r.name.as_str())
.filter(|n| CURVES.contains(n) || SURFACES.contains(n))
.collect();
let kind = format!("({}){rational}", names.join(" "));
return match (is_curve, is_surface) {
(true, false) => Some((true, kind)),
(false, true) => Some((false, kind)),
_ => None,
};
}
let n = e.name();
if CURVES.contains(&n) {
Some((true, n.to_string()))
} else if SURFACES.contains(&n) {
Some((false, n.to_string()))
} else {
None
}
}
/// An error message with its entity ids replaced by `#n`, so equal reasons count together.
fn reason(e: &str) -> String {
let mut out = String::new();
let mut chars = e.chars().peekable();
while let Some(c) = chars.next() {
out.push(c);
if c == '#' && chars.peek().is_some_and(char::is_ascii_digit) {
out.push('n');
while chars.peek().is_some_and(char::is_ascii_digit) {
chars.next();
}
}
}
out
}
/// The curves of a file that lie in a parameter space: the items of its
/// `DEFINITIONAL_REPRESENTATION`s and the curves they refer to (a 2D trimmed curve's basis …).
fn curves_2d(ex: &p21::Exchange) -> std::collections::BTreeSet<u64> {
fn refs(p: &p21::Param, out: &mut Vec<u64>) {
match p {
p21::Param::Ref(r) => out.push(*r),
p21::Param::List(l) | p21::Param::Typed(_, l) => l.iter().for_each(|x| refs(x, out)),
_ => {}
}
}
let mut stack = Vec::new();
for e in ex.entities.values() {
if e.name() == "DEFINITIONAL_REPRESENTATION"
&& let Some(items) = e.params().get(1)
{
refs(items, &mut stack);
}
}
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = stack.pop() {
let Some(e) = ex.get(id) else { continue };
if !matches!(geometry_kind(e), Some((true, _))) || !seen.insert(id) {
continue;
}
for r in &e.records {
r.params.iter().for_each(|p| refs(p, &mut stack));
}
}
seen
}
/// Read or refused, by reason.
#[derive(Default)]
struct Tally {
read: usize,
refused: BTreeMap<String, usize>,
}
impl Tally {
fn add<T>(&mut self, r: &Result<T, String>) {
match r {
Ok(_) => self.read += 1,
Err(e) => *self.refused.entry(reason(e)).or_default() += 1,
}
}
fn refused(&self) -> usize {
self.refused.values().sum()
}
}
#[test]
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
fn corpus_geometry() {
use solidcore_record::step::read::Reader;
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
return;
};
let root = Path::new(&dir);
let mut paths = Vec::new();
files(root, &mut paths);
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
let mut failures = Vec::new();
// (group, kind) -> tally, groups "curve", "surface", "pcurve" (by its 2D curve's kind).
let mut kinds: BTreeMap<(&str, String), Tally> = BTreeMap::new();
let mut mixed_units = Vec::new();
let mut removed = Vec::new();
for path in &paths {
let name = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string();
let ex = match std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
{
Ok(ex) => ex,
Err(e) => {
println!("{name}: ERROR {e}");
failures.push(format!("{name}: {e}"));
continue;
}
};
// The units of the file's first geometric context (the topology reader, which comes
// later, reads each representation in its own context).
let contexts: Vec<units::Units> = ex
.entities
.iter()
.filter(|(_, e)| {
e.has("GEOMETRIC_REPRESENTATION_CONTEXT") && e.has("GLOBAL_UNIT_ASSIGNED_CONTEXT")
})
.map(|(&id, _)| units::context(&ex, id).units)
.collect();
let u = contexts.first().copied().unwrap_or_default();
if contexts.iter().any(|c| *c != u) {
mixed_units.push(name.clone());
}
// Where a removed check would have decided otherwise: SolidCraft refused a direction
// whose length is at most 1e-12 (here: only one that is exactly zero), and took the two
// unit factors of a conic's parameter space as equal within 1e-12 relative (here: only
// when equal).
for (&id, e) in &ex.entities {
if e.name() == "DIRECTION"
&& let Some(r) = e.params().get(1).and_then(p21::Param::as_list)
{
let sq: f64 = r.iter().filter_map(p21::Param::as_f64).map(|x| x * x).sum();
if sq > 0.0 && sq <= 1e-24 {
removed.push(format!("{name}: #{id} a direction of length at most 1e-12"));
}
}
}
if u.len != u.ang && (u.len - u.ang).abs() <= 1e-12 * u.len.abs().max(u.ang.abs()) {
removed.push(format!(
"{name}: length and angle factors equal within 1e-12"
));
}
let flat = curves_2d(&ex);
let mut reader = Reader::new(&ex, u);
let mut here = (Tally::default(), Tally::default(), Tally::default());
for (&id, e) in &ex.entities {
if e.name() == "PCURVE" {
let kind = e
.params()
.get(2)
.and_then(p21::Param::as_ref_id)
.and_then(|r| ex.get(r))
.and_then(|r| r.params().get(1)?.as_list()?.first()?.as_ref_id())
.and_then(|c| ex.get(c))
.and_then(geometry_kind)
.map_or_else(|| "?".to_string(), |k| k.1);
let r = reader.pcurve(id);
here.2.add(&r);
kinds.entry(("pcurve", kind)).or_default().add(&r);
continue;
}
let Some((is_curve, kind)) = geometry_kind(e) else {
continue;
};
if is_curve {
if flat.contains(&id) {
continue;
}
let r = reader.curve(id);
here.0.add(&r);
kinds.entry(("curve", kind)).or_default().add(&r);
} else {
let r = reader.surface(id);
here.1.add(&r);
kinds.entry(("surface", kind)).or_default().add(&r);
}
}
println!(
"{name}: curves {} read, {} refused; surfaces {} read, {} refused; pcurves {} read, {} refused",
here.0.read,
here.0.refused(),
here.1.read,
here.1.refused(),
here.2.read,
here.2.refused()
);
}
println!("by kind (group, kind: read / refused, with reasons):");
let mut totals: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
for ((group, kind), t) in &kinds {
println!(" {group} {kind}: {} / {}", t.read, t.refused());
for (why, n) in &t.refused {
println!(" {n} x {why}");
}
let g = totals.entry(group).or_default();
g.0 += t.read;
g.1 += t.refused();
}
println!("totals (read / refused): {totals:?}");
println!(
"files whose contexts have different units (read in the first): {} {mixed_units:?}",
mixed_units.len()
);
println!(
"entities a removed check would have decided otherwise: {} {removed:?}",
removed.len()
);
assert!(
failures.is_empty(),
"files that did not parse:\n{}",
failures.join("\n")
);
}
/// The entity types counted by `corpus_product` against what the product structure holds.
const BODY_KINDS: [&str; 4] = [
"MANIFOLD_SOLID_BREP",
"BREP_WITH_VOIDS",
"FACETED_BREP",
"SHELL_BASED_SURFACE_MODEL",
];
const OCCURRENCE_KINDS: [&str; 3] = [
"NEXT_ASSEMBLY_USAGE_OCCURRENCE",
"ASSEMBLY_COMPONENT_USAGE",
"SPECIFIED_HIGHER_USAGE_OCCURRENCE",
];
const STYLED_KINDS: [&str; 3] = [
"STYLED_ITEM",
"OVER_RIDING_STYLED_ITEM",
"CONTEXT_DEPENDENT_OVER_RIDING_STYLED_ITEM",
];
#[test]
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
fn corpus_product() {
use solidcore_record::step::product::{self, ItemKind};
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
return;
};
let root = Path::new(&dir);
let mut paths = Vec::new();
files(root, &mut paths);
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
let mut failures = Vec::new();
let mut assemblies = Vec::new();
// Totals: name -> (held, census).
let mut totals: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
let mut coloured_by_kind: BTreeMap<String, usize> = BTreeMap::new();
let mut instances_total = 0usize;
let mut unreached_mapped: BTreeMap<String, usize> = BTreeMap::new();
for path in &paths {
let name = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string();
let ex = match std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
{
Ok(ex) => ex,
Err(e) => {
println!("{name}: ERROR {e}");
failures.push(format!("{name}: {e}"));
continue;
}
};
let census = |names: &[&str]| {
ex.entities
.values()
.filter(|e| names.contains(&e.name()))
.count()
};
let s = match product::read(&ex) {
Ok(s) => s,
Err(e) => {
println!("{name}: REFUSED {e}");
failures.push(format!("{name}: refused: {e}"));
continue;
}
};
let mut bodies = std::collections::BTreeSet::new();
let mut mapped = std::collections::BTreeSet::new();
for r in &s.representations {
for i in &r.items {
match i.kind {
ItemKind::Body(_) => {
bodies.insert(i.entity);
}
ItemKind::Mapped { .. } => {
mapped.insert(i.entity);
}
ItemKind::Other(_) => {}
}
}
}
let placed = s
.links
.iter()
.filter(|l| l.transformation.is_some())
.count();
let coloured: Vec<_> = s
.styles
.iter()
.filter(|st| {
!st.curve.is_empty()
|| st
.surface
.iter()
.any(|f| f.fill.is_some() || f.rendering.is_some())
})
.collect();
for st in &coloured {
let kind = ex.get(st.item).map_or("?", p21::Entity::name);
*coloured_by_kind.entry(kind.to_string()).or_default() += 1;
}
let instances = match s.instances() {
Ok(i) => i.len(),
Err(e) => {
failures.push(format!("{name}: instances: {e}"));
0
}
};
instances_total += instances;
let rows = [
("products", s.products.len(), census(&["PRODUCT"])),
(
"definitions",
s.definitions.len(),
census(&[
"PRODUCT_DEFINITION",
"PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS",
]),
),
(
"occurrences",
s.occurrences.len(),
census(&OCCURRENCE_KINDS),
),
("body items", bodies.len(), census(&BODY_KINDS)),
(
"placed links",
placed,
ex.entities
.values()
.filter(|e| e.has("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION"))
.count(),
),
("styled items", s.styles.len(), census(&STYLED_KINDS)),
];
// A mapped item the structure does not hold is listed only by representations it does
// not reach (the draughting models of PMI views): checked, and counted apart.
let reached: std::collections::BTreeSet<u64> =
s.representations.iter().map(|r| r.entity).collect();
let mut listers: BTreeMap<u64, Vec<(u64, &str)>> = BTreeMap::new();
for (&id, e) in &ex.entities {
for r in &e.records {
for p in &r.params {
for m in p
.as_list()
.unwrap_or_default()
.iter()
.filter_map(p21::Param::as_ref_id)
{
if ex.get(m).is_some_and(|m| m.name() == "MAPPED_ITEM") {
listers.entry(m).or_default().push((id, e.name()));
}
}
}
}
}
let all_mapped = census(&["MAPPED_ITEM"]);
for (&id, e) in &ex.entities {
if e.name() != "MAPPED_ITEM" || mapped.contains(&id) {
continue;
}
let by = listers.get(&id).cloned().unwrap_or_default();
if by.iter().any(|(l, _)| reached.contains(l)) {
failures.push(format!(
"{name}: mapped item #{id} of a reached representation not held"
));
}
for (_, kind) in by {
*unreached_mapped.entry(kind.to_string()).or_default() += 1;
}
}
let t = totals.entry("mapped items").or_default();
t.0 += mapped.len();
t.1 += all_mapped;
let mut line = format!("{name}: mapped items {}/{all_mapped};", mapped.len());
for (what, held, n) in rows {
let _ = std::fmt::Write::write_fmt(&mut line, format_args!(" {what} {held}/{n};"));
let t = totals.entry(what).or_default();
t.0 += held;
t.1 += n;
if held != n {
failures.push(format!("{name}: {what}: {held} held, {n} in the file"));
}
}
println!(
"{line} coloured items {}; roots {}; instances {instances}",
coloured.len(),
s.roots.len()
);
if !s.occurrences.is_empty() {
assemblies.push(format!(
"{name}: {} definitions, {} occurrences, {} roots, {instances} instances, {} mapped items",
s.definitions.len(),
s.occurrences.len(),
s.roots.len(),
mapped.len()
));
}
}
println!("totals (held / in the files): {totals:?}");
println!("coloured items by kind: {coloured_by_kind:?}");
println!("mapped items not held, by the kind of entity listing them: {unreached_mapped:?}");
println!("instances: {instances_total}");
println!("files with assemblies: {}", assemblies.len());
for a in &assemblies {
println!(" {a}");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
+9 -36
View File
@@ -474,19 +474,12 @@ fn plan_at(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result<(TessPl
.map(|(fi, r)| refused.take(fi, r).flatten())
.collect();
let face_h = |f: usize| sizes.get(f).and_then(Option::as_ref).map(|s| s.h);
// A flat face asks for no size (infinite): an edge between flat faces is whole, one beside a
// curved face takes the curved face's size, so both meet at the same nodes. An edge that
// bounds no face keeps the longest side.
let edge_h: Vec<f64> = edge_faces
.iter()
.map(|fs| {
if fs.is_empty() {
rules.max_len
} else {
fs.iter()
.filter_map(|&f| face_h(f))
.fold(f64::INFINITY, f64::min)
}
fs.iter()
.filter_map(|&f| face_h(f))
.fold(rules.max_len, f64::min)
})
.collect();
let jobs: Vec<(usize, f64)> = edge_h.into_iter().enumerate().collect();
@@ -2131,15 +2124,8 @@ fn plan_face(
// refined where triangles leave the surface, are too long or bend too much, then shaped by
// Ruppert refinement.
let step = [h / speed[0], h / speed[1]];
// A flat face with no size asked of it is its boundary nodes alone: no interior seeds, no
// refinement.
let bare = matches!(s, EvalSurface::Plane { .. }) && h.is_infinite();
let delaunay = || -> std::result::Result<_, FaceError> {
let seeds = if bare {
Vec::new()
} else {
cdt::lattice(bbox, step, &polys, speed)
};
let seeds = cdt::lattice(bbox, step, &polys, speed);
let eval = |u: f64, v: f64| sag_eval(s, u, v);
let sag = cdt::Sag {
eval: &eval,
@@ -2150,7 +2136,7 @@ fn plan_face(
};
let fill = cdt::Fill {
seeds: &seeds,
refine: if bare { None } else { Some((sag, MAX_REFINE)) },
refine: Some((sag, MAX_REFINE)),
shape: !matches!(s, EvalSurface::Plane { .. }),
lenient: lenient == Lenient::Yes,
};
@@ -2164,9 +2150,6 @@ fn plan_face(
// The lenient fill is the constrained Delaunay alone: the caller names the face in the stats.
let (extra, triangles) = if lenient == Lenient::Yes {
delaunay()?
} else if bare {
stats.planar += 1;
delaunay()?
} else {
// A point of the box outside the face's loops is not the face's (as for the size
// samples above); one they cannot tell about, past MAX_PERIODS, refuses the face.
@@ -2315,26 +2298,16 @@ fn plan_face(
}
// Whatever the layout, no triangle leaves the surface by more than the tolerance.
if !planar {
// The kernel's classifier is asked once per node over the three passes.
let mut poles = sag::Poles::new(s, reach);
if sag::refine(
s,
tol,
&mut poles,
&fixed,
&mut all,
&mut triangles,
MAX_REFINE,
)
.map_err(|e| sag_stop(e, all.len()))?
if sag::refine(s, tol, reach, &fixed, &mut all, &mut triangles, MAX_REFINE)
.map_err(|e| sag_stop(e, all.len()))?
{
caps.push(Cap::Refine);
}
sag::flip(s, tol, &mut poles, &fixed, &all, &mut triangles)
sag::flip(s, tol, reach, &fixed, &all, &mut triangles)
.map_err(|e| sag_stop(e, all.len()))?;
// A face a bound limited is named with the sag its triangles reach.
if !caps.is_empty() {
let (sag, unmeasured) = sag::worst(s, &mut poles, &fixed, &all, &triangles)
let (sag, unmeasured) = sag::worst(s, reach, &fixed, &all, &triangles)
.map_err(|e| sag_stop(e, all.len()))?;
stats.over_tolerance.push(OverTolerance {
face: fi as usize,
+2 -15
View File
@@ -379,14 +379,7 @@ fn the_flips_name_a_middle_the_surface_does_not_evaluate_at() {
let uv = vec![[0.0, 0.0], [0.75, -0.5], [0.75, 0.5], [0.0, 1.0]];
let fixed: Vec<Vec3> = uv.iter().map(|q| point(&s, *q)).collect();
let mut tris = vec![[0, 1, 2], [0, 2, 3]];
match super::sag::flip(
&s,
0.01,
&mut super::sag::Poles::new(&s, 1e-9),
&fixed,
&uv,
&mut tris,
) {
match super::sag::flip(&s, 0.01, 1e-9, &fixed, &uv, &mut tris) {
Err(super::sag::Stop::NoValue(NoValue(q))) => assert_eq!(q[0], 0.5, "{q:?}"),
other => panic!("{other:?}, flipped to {tris:?}"),
}
@@ -551,13 +544,7 @@ fn a_step_inwards_the_surface_has_no_point_at_refuses_the_face() {
fn the_worst_sag_is_measured_where_the_surface_evaluates() {
let s = cylinder_without(0.5);
let uv = [[0.0, 0.0], [0.75, -0.5], [0.75, 0.5]];
let got = super::sag::worst(
&s,
&mut super::sag::Poles::new(&s, 1e-9),
&[],
&uv,
&[[0, 1, 2]],
);
let got = super::sag::worst(&s, 1e-9, &[], &uv, &[[0, 1, 2]]);
// Measured at its three sides' middles; its middle, unmeasured, is counted, so the face is
// never taken as within its tolerance over it.
assert!(
+81 -197
View File
@@ -18,9 +18,8 @@ use super::{NoValue, Unclassified};
/// Split the triangles `tris` of a face (indices into `uv`; the first `fixed.len()` points sit
/// at `fixed`, the rest on the surface at their parameters) until none of them lies farther
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `poles`: the
/// classifier of the face's nodes, shared by [`refine`], [`flip`] and [`worst`] (how near a pole a
/// point is taken as one: see [`solidcore::eval::uv::degenerate_within`]).
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `reach`: how near
/// a pole a point is taken as one (see [`solidcore::eval::uv::degenerate_within`]).
///
/// A point the surface does not evaluate at (a node, or a middle the check needs) is the
/// evaluator refusing the face: the error names it, and the caller leaves the face out whole.
@@ -51,7 +50,7 @@ impl From<Unclassified> for Stop {
pub(crate) fn refine(
s: &EvalSurface,
tol: f64,
poles: &mut Poles,
reach: f64,
fixed: &[Vec3],
uv: &mut Vec<[f64; 2]>,
tris: &mut Vec<[u32; 3]>,
@@ -65,6 +64,7 @@ pub(crate) fn refine(
.map(|d| kernel::record_vec3(d.0))
.ok_or(NoValue(q))
};
let mean = |qs: &[[f64; 2]]| mean(s, reach, qs);
// How far the flat point `c` lies from the surface, measured along the normal at the surface
// point of the same parameters: those parameters need not be the nearest (a straight line in
// the parameters is no geodesic near a pole), and only the departure across the surface
@@ -103,7 +103,10 @@ pub(crate) fn refine(
continue;
};
let p = |i: u32| pos.get(i as usize).copied();
let (Some(pa), Some(pb), Some(pc)) = (p(t[0]), p(t[1]), p(t[2])) else {
let q = |i: u32| uv.get(i as usize).copied();
let (Some(pa), Some(pb), Some(pc), Some(qa), Some(qb), Some(qc)) =
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
else {
continue;
};
// Sides inside the face (two triangles on them), long enough to split, with their sag.
@@ -114,7 +117,7 @@ pub(crate) fn refine(
if sides.get(&e).is_none_or(|ts| ts.len() != 2) {
continue;
}
let (Some(xa), Some(xb)) = (p(a), p(b)) else {
let (Some(xa), Some(xb), Some(ua), Some(ub)) = (p(a), p(b), q(a), q(b)) else {
continue;
};
let len = xa.dist(xb);
@@ -124,7 +127,7 @@ pub(crate) fn refine(
let sag = if let Some(&sag) = sag_of.get(&e) {
sag
} else {
let sag = off(poles.mean(uv, &[a, b])?, (xa + xb) * 0.5)?;
let sag = off(mean(&[ua, ub])?, (xa + xb) * 0.5)?;
sag_of.insert(e, sag);
sag
};
@@ -135,7 +138,7 @@ pub(crate) fn refine(
// inside the face divides by four. A triangle whose middle is off splits its side inside
// the face that sags most, while that side still contributes; what is left comes from
// its boundary sides, which the edges' own sampling keeps within the tolerance.
let centroid = poles.mean(uv, &t)?;
let centroid = mean(&[qa, qb, qc])?;
let worst = inner.iter().max_by(|x, y| x.0.total_cmp(&y.0)).copied();
let split = match worst {
Some((sag, _, e)) if sag > tol => e,
@@ -171,11 +174,14 @@ pub(crate) fn refine(
if from != ti {
queue.push(ti);
}
let (Some(ua), Some(ub)) = (q(split.0), q(split.1)) else {
continue;
};
// A split is wanted and there is no room for its point.
if uv.len() >= limit {
return Ok(true);
}
let m = poles.mean(uv, &[split.0, split.1])?;
let m = mean(&[ua, ub])?;
let pm = at(m)?;
// Proven: `plan_face` checks its nodes and the MAX_REFINE more it lets this add are at most
// MAX_FACE_NODES, so the index fits a u32.
@@ -188,82 +194,29 @@ pub(crate) fn refine(
Ok(false)
}
/// Which parameter directions the surface is collapsed along at each node of a face's
/// triangulation, as the kernel's classifier says (within `reach`): a property of the node's
/// parameters, so it is asked once per node however many triangles and rounds look at it.
pub(crate) struct Poles<'a> {
s: &'a EvalSurface,
reach: f64,
/// Per node index; `None` until asked.
flags: Vec<Option<[bool; 2]>>,
/// Calls of the kernel's classifier made, for the tests.
#[cfg(test)]
asked: usize,
}
impl<'a> Poles<'a> {
pub(crate) fn new(s: &'a EvalSurface, reach: f64) -> Self {
Poles {
s,
reach,
flags: Vec::new(),
#[cfg(test)]
asked: 0,
}
}
/// Whether each direction is collapsed at node `i`, whose parameters are `q`.
fn at(&mut self, i: u32, q: [f64; 2]) -> Result<[bool; 2], Unclassified> {
let i = i as usize;
if let Some(Some(f)) = self.flags.get(i) {
return Ok(*f);
}
#[cfg(test)]
{
self.asked += 2;
}
let f = [
super::pole_at(self.s, q, 0, self.reach)?,
super::pole_at(self.s, q, 1, self.reach)?,
];
if self.flags.len() <= i {
self.flags.resize(i + 1, None);
}
if let Some(slot) = self.flags.get_mut(i) {
*slot = Some(f);
}
Ok(f)
}
/// The mean of nodes' parameters, as the surface point between them: at a pole the
/// parameter along the degenerate direction is free (the pole copy carries a neighbour's),
/// so there the mean takes it from the other nodes alone. A node whose poles the kernel's
/// classifier cannot tell is the error.
fn mean(&mut self, uv: &[[f64; 2]], nodes: &[u32]) -> Result<[f64; 2], Unclassified> {
let mut qs: Vec<([f64; 2], [bool; 2])> = Vec::new();
for &i in nodes {
if let Some(&q) = uv.get(i as usize) {
qs.push((q, self.at(i, q)?));
/// The mean of parameter points, as the surface point between them: at a pole the parameter
/// along the degenerate direction is free (the pole copy carries a neighbour's), so there the
/// mean takes it from the other points alone.
/// A point whose poles the kernel's classifier cannot tell is the error.
fn mean(s: &EvalSurface, reach: f64, qs: &[[f64; 2]]) -> Result<[f64; 2], Unclassified> {
let mut out = [0.0; 2];
// Proven: `d` < 2 enumerates `out` and indexes the `[f64; 2]` parameters `qs`.
#[allow(clippy::indexing_slicing)]
for (d, slot) in out.iter_mut().enumerate() {
let mut free: Vec<f64> = Vec::new();
for q in qs {
if !super::pole_at(s, *q, d, reach)? {
free.push(q[d]);
}
}
let mut out = [0.0; 2];
// Proven: `d` < 2 enumerates `out` and indexes the `[f64; 2]` parameters and flags.
#[allow(clippy::indexing_slicing)]
for (d, slot) in out.iter_mut().enumerate() {
let free: Vec<f64> = qs
.iter()
.filter(|(_, f)| !f[d])
.map(|(q, _)| q[d])
.collect();
let pick: Vec<f64> = if free.is_empty() {
qs.iter().map(|(q, _)| q[d]).collect()
} else {
free
};
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
}
Ok(out)
let pick: Vec<f64> = if free.is_empty() {
qs.iter().map(|q| q[d]).collect()
} else {
free
};
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
}
Ok(out)
}
/// The farthest the triangles `tris` (as for [`refine`]) lie from the surface, measured as
@@ -276,7 +229,7 @@ impl<'a> Poles<'a> {
/// classifier cannot tell.
pub(crate) fn worst(
s: &EvalSurface,
poles: &mut Poles,
reach: f64,
fixed: &[Vec3],
uv: &[[f64; 2]],
tris: &[[u32; 3]],
@@ -292,8 +245,8 @@ pub(crate) fn worst(
})
.collect();
// How far the flat point `c` lies from the surface at the mean of `qs`, as `refine`'s `off`.
let mut off = |nodes: &[u32], c: Vec3| -> Result<Option<f64>, Unclassified> {
let m = poles.mean(uv, nodes)?;
let off = |qs: &[[f64; 2]], c: Vec3| -> Result<Option<f64>, Unclassified> {
let m = mean(s, reach, qs)?;
let Some((x, su, sv)) = s.derivs(m[0], m[1]).map(kernel::record_triple) else {
return Ok(None);
};
@@ -306,7 +259,7 @@ pub(crate) fn worst(
let (mut worst, mut unmeasured) = (0.0f64, 0usize);
for (k, t) in tris.iter().enumerate() {
let corner = |i: u32| Some((*uv.get(i as usize)?, *pos.get(i as usize)?));
let (Some((_, pa)), Some((_, pb)), Some((_, pc))) =
let (Some((qa, pa)), Some((qb, pb)), Some((qc, pc))) =
(corner(t[0]), corner(t[1]), corner(t[2]))
else {
return Err(Stop::Corner(k));
@@ -315,16 +268,16 @@ pub(crate) fn worst(
// corner they need).
let mut measured: Vec<Option<f64>> = Vec::new();
measured.push(match (pa, pb, pc) {
(Some(a), Some(b), Some(c)) => off(t, (a + b + c) * (1.0 / 3.0))?,
(Some(a), Some(b), Some(c)) => off(&[qa, qb, qc], (a + b + c) * (1.0 / 3.0))?,
_ => None,
});
for ((nx, px), (ny, py)) in [
((t[0], pa), (t[1], pb)),
((t[1], pb), (t[2], pc)),
((t[2], pc), (t[0], pa)),
for ((qx, px), (qy, py)) in [
((qa, pa), (qb, pb)),
((qb, pb), (qc, pc)),
((qc, pc), (qa, pa)),
] {
measured.push(match (px, py) {
(Some(x), Some(y)) => off(&[nx, ny], (x + y) * 0.5)?,
(Some(x), Some(y)) => off(&[qx, qy], (x + y) * 0.5)?,
_ => None,
});
}
@@ -435,7 +388,7 @@ const MIN_FLIP_ANGLE: f64 = 1e-3;
pub(crate) fn flip(
s: &EvalSurface,
tol: f64,
poles: &mut Poles,
reach: f64,
fixed: &[Vec3],
uv: &[[f64; 2]],
tris: &mut [[u32; 3]],
@@ -443,6 +396,7 @@ pub(crate) fn flip(
if !(tol.is_finite() && tol > 0.0) || tris.len() < 2 {
return Ok(());
}
let mean = |qs: &[[f64; 2]]| mean(s, reach, qs);
let derivs = |q: [f64; 2]| {
s.derivs(q[0], q[1])
.map(kernel::record_triple)
@@ -487,11 +441,13 @@ pub(crate) fn flip(
angle(a, b, c).min(angle(b, c, a)).min(angle(c, a, b))
};
// How far a facet's normal turns from the surface normal at its middle (radians, 0 to pi/2).
let deviation = |poles: &mut Poles, t: [u32; 3]| -> Result<f64, Stop> {
let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) else {
let deviation = |t: [u32; 3]| -> Result<f64, Stop> {
let (Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) =
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
else {
return Ok(0.0);
};
let m = poles.mean(uv, &t)?;
let m = mean(&[qa, qb, qc])?;
let facet = (b - a).cross(c - a).normalized();
let (_, su, sv) = derivs(m)?;
Ok(match (facet, su.cross(sv).normalized()) {
@@ -502,16 +458,20 @@ pub(crate) fn flip(
};
// Whether the facet's normal lies on the side of the surface normal that its (u, v)
// orientation `sign` says.
let upright = |poles: &mut Poles, t: [u32; 3], sign: f64| -> Result<bool, Stop> {
let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) else {
let upright = |t: [u32; 3], sign: f64| -> Result<bool, Stop> {
let (Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) =
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
else {
return Ok(false);
};
let (_, su, sv) = derivs(poles.mean(uv, &t)?)?;
let (_, su, sv) = derivs(mean(&[qa, qb, qc])?)?;
Ok((b - a).cross(c - a).dot(su.cross(sv)) * sign > 0.0)
};
let centroid_off = |poles: &mut Poles, t: [u32; 3]| -> Result<f64, Stop> {
match (p(t[0]), p(t[1]), p(t[2])) {
(Some(a), Some(b), Some(c)) => Ok(off(poles.mean(uv, &t)?, (a + b + c) * (1.0 / 3.0))?),
let centroid_off = |t: [u32; 3]| -> Result<f64, Stop> {
match (p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2])) {
(Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) => {
Ok(off(mean(&[qa, qb, qc])?, (a + b + c) * (1.0 / 3.0))?)
}
_ => Ok(f64::INFINITY),
}
};
@@ -563,20 +523,20 @@ pub(crate) fn flip(
// The pair the other way must lie closer along the surface (its facets turn less from
// the surface normal), by more than a degree: a pair within a degree of the surface is
// left as the layout made it.
let mut cached = |poles: &mut Poles, ti: u32, t: [u32; 3]| -> Result<f64, Stop> {
let mut cached = |ti: u32, t: [u32; 3]| -> Result<f64, Stop> {
if let Some(d) = dev_of.get(ti as usize).copied().flatten() {
Ok(d)
} else {
let d = deviation(poles, t)?;
let d = deviation(t)?;
if let Some(slot) = dev_of.get_mut(ti as usize) {
*slot = Some(d);
}
Ok(d)
}
};
let worse = cached(poles, t1, ta)?.max(cached(poles, t2, tb)?);
let worse = cached(t1, ta)?.max(cached(t2, tb)?);
let degree = 1f64.to_radians();
if worse < degree || deviation(poles, n1)?.max(deviation(poles, n2)?) >= worse - degree {
if worse < degree || deviation(n1)?.max(deviation(n2)?) >= worse - degree {
continue;
}
// ... and have area in 3D (three corners on one line of a cone have none, however they
@@ -584,19 +544,21 @@ pub(crate) fn flip(
if min_angle(n1).min(min_angle(n2)) < MIN_FLIP_ANGLE {
continue;
}
if !(upright(poles, n1, sign)? && upright(poles, n2, sign)?) {
if !(upright(n1, sign)? && upright(n2, sign)?) {
continue;
}
let (Some(pa), Some(pb), Some(pc), Some(pd)) = (p(a), p(b), p(c), p(d)) else {
let (Some(pa), Some(pb), Some(pc), Some(pd), Some(qa), Some(qb), Some(qc), Some(qd)) =
(p(a), p(b), p(c), p(d), q(a), q(b), q(c), q(d))
else {
continue;
};
let allowed = tol
.max(off(poles.mean(uv, &[a, b])?, (pa + pb) * 0.5)?)
.max(centroid_off(poles, ta)?)
.max(centroid_off(poles, tb)?);
if off(poles.mean(uv, &[c, d])?, (pc + pd) * 0.5)? > allowed
|| centroid_off(poles, n1)? > allowed
|| centroid_off(poles, n2)? > allowed
.max(off(mean(&[qa, qb])?, (pa + pb) * 0.5)?)
.max(centroid_off(ta)?)
.max(centroid_off(tb)?);
if off(mean(&[qc, qd])?, (pc + pd) * 0.5)? > allowed
|| centroid_off(n1)? > allowed
|| centroid_off(n2)? > allowed
{
continue;
}
@@ -645,15 +607,7 @@ mod tests {
assert_eq!(fixed.len(), 4);
let mut tris = vec![[0, 1, 2], [0, 2, 3]];
assert_eq!(
refine(
&s,
0.01,
&mut Poles::new(&s, 1e-9),
&fixed,
&mut uv,
&mut tris,
1000
),
refine(&s, 0.01, 1e-9, &fixed, &mut uv, &mut tris, 1000),
Err(Stop::NoValue(NoValue([0.5, 0.5])))
);
assert_eq!(tris, vec![[0, 1, 2], [0, 2, 3]]);
@@ -674,19 +628,11 @@ mod tests {
assert_eq!(fixed.len(), 4);
let mut tris = vec![[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]];
assert_eq!(
refine(
&s,
0.01,
&mut Poles::new(&s, 1e-9),
&fixed,
&mut uv,
&mut tris,
1000
),
refine(&s, 0.01, 1e-9, &fixed, &mut uv, &mut tris, 1000),
Err(Stop::NoValue(NoValue([0.5, 0.5])))
);
assert_eq!(
flip(&s, 0.01, &mut Poles::new(&s, 1e-9), &fixed, &uv, &mut tris),
flip(&s, 0.01, 1e-9, &fixed, &uv, &mut tris),
Err(Stop::NoValue(NoValue([0.5, 0.5])))
);
}
@@ -715,7 +661,7 @@ mod tests {
};
let uv = [[0.5, -0.1], [0.5, 0.1], [0.6, 0.0]];
let side = 3.0 * (1.0 - 0.1f64.cos());
let got = worst(&s, &mut Poles::new(&s, 0.0), &[], &uv, &[[0, 1, 2]]);
let got = worst(&s, 0.0, &[], &uv, &[[0, 1, 2]]);
assert!(
got.as_ref()
.is_ok_and(|(w, unmeasured)| (w - side).abs() < 1e-9 * side && *unmeasured == 0),
@@ -723,70 +669,8 @@ mod tests {
);
// A corner past the nodes is the triangle's index.
assert_eq!(
worst(
&s,
&mut Poles::new(&s, 0.0),
&[],
&uv,
&[[0, 1, 2], [0, 1, 3]]
),
worst(&s, 0.0, &[], &uv, &[[0, 1, 2], [0, 1, 3]]),
Err(Stop::Corner(1))
);
}
/// Refining, flipping and measuring a coarse patch of a cylinder (every sag over the
/// tolerance, so the nodes are looked at by many triangles, sides and rounds) asks the
/// kernel's classifier once per node and direction, not once per look.
#[test]
fn the_classifier_is_asked_once_per_node() {
use solidcore::eval::EvalCurve;
let s = EvalSurface::Revolved {
profile: EvalCurve::new(
1,
vec![0.0, 0.0, 1.0, 1.0],
vec![[3.0, 0.0, 0.0, 1.0], [3.0, 0.0, 4.0, 1.0]],
),
origin: solidcore::Vec3::ZERO,
axis: solidcore::Vec3::Z,
transform: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
],
swap: false,
};
let n = 6u32;
let mut uv: Vec<[f64; 2]> = Vec::new();
for i in 0..=n {
for j in 0..=n {
uv.push([
f64::from(i) / f64::from(n),
3.0 * f64::from(j) / f64::from(n),
]);
}
}
let mut tris: Vec<[u32; 3]> = Vec::new();
for i in 0..n {
for j in 0..n {
let a = i * (n + 1) + j;
let (b, c, d) = (a + 1, a + n + 1, a + n + 2);
tris.push([a, c, d]);
tris.push([a, d, b]);
}
}
let mut poles = Poles::new(&s, 1e-9);
let start = uv.len();
// Refinement is capped (it may or may not run out of room): the count holds either way.
refine(&s, 5e-2, &mut poles, &[], &mut uv, &mut tris, 300).unwrap();
flip(&s, 5e-2, &mut poles, &[], &uv, &mut tris).unwrap();
worst(&s, &mut poles, &[], &uv, &tris).unwrap();
assert!(uv.len() > 2 * start, "{} nodes: not refined", uv.len());
assert!(poles.asked > 0);
assert!(
poles.asked <= 2 * uv.len(),
"{} calls for {} nodes",
poles.asked,
uv.len()
);
}
}
+7 -15
View File
@@ -1,9 +1,7 @@
//! Target triangle size: one side length per face, so triangles come out about equilateral
//! (well-shaped triangles shade evenly) and as small as the surface needs.
//!
//! A flat face (a plane, or a spline lying in one) has no size: any triangulation of its
//! boundary is exact, so it gets no interior cells and its straight edges no extra nodes.
//! A curved face's size is the smallest of the body's longest side, the length over which the surface
//! A face's size is the smallest of the body's longest side, the length over which the surface
//! normal turns the caller's facet angle (when one is given, see [`Turn`]), and the chord that
//! sags the tolerance; an edge takes the smaller size of its two faces, so a curved face and
//! its flat neighbour meet at the same spacing.
@@ -143,7 +141,7 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> FaceSiz
};
if let EvalSurface::Plane { du, dv, .. } = s {
return plain(
f64::INFINITY,
rules.max_len,
[
kernel::record_vec3(*du).len(),
kernel::record_vec3(*dv).len(),
@@ -155,8 +153,7 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> FaceSiz
};
let mut unevaluated: Vec<[f64; 2]> = Vec::new();
// A flat spline (sheet-metal walls, chamfers): no curvature to look for, only the speeds.
let flat = planar(s);
let samples = if flat { 3 } else { SAMPLES };
let samples = if planar(s) { 3 } else { SAMPLES };
// The point, the unit normal (`None` at a pole, where it is undefined) and the speeds.
let normal = |u: f64, v: f64| -> Result<(Vec3, Option<Vec3>, [f64; 2]), [f64; 2]> {
let (p, su, sv) = s.derivs(u, v).map(kernel::record_triple).ok_or([u, v])?;
@@ -206,12 +203,7 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> FaceSiz
[1.0, 1.0]
};
FaceSize {
// A flat face is exact for any triangulation of its boundary: no length cap.
h: if flat {
f64::INFINITY
} else {
rules.for_curvature(k_max)
},
h: rules.for_curvature(k_max),
speed: speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }),
unevaluated,
}
@@ -356,7 +348,7 @@ mod tests {
&solidcore::eval::uv::domain(&patch(0.0)),
&rules,
);
assert_eq!(h, f64::INFINITY);
assert_eq!(h, rules.max_len);
assert!(
(speed[0] - 4.0).abs() < 1e-9 && (speed[1] - 3.0).abs() < 1e-9,
"{speed:?}"
@@ -395,7 +387,7 @@ mod tests {
}
#[test]
fn a_plane_asks_for_no_side() {
fn a_plane_takes_the_longest_side() {
let s = EvalSurface::Plane {
origin: solidcore::Vec3::ZERO,
du: solidcore::Vec3::X,
@@ -404,7 +396,7 @@ mod tests {
let rules = Rules::new(0.01, 100.0, THIRTY);
assert_eq!(
face_size(&s, &solidcore::eval::uv::domain(&s), &rules).h,
f64::INFINITY
20.0
);
}
}
+2 -18
View File
@@ -61,28 +61,12 @@ pub(crate) fn crossing(
if n < 3 {
continue;
}
// Each node is on a pole or not whichever two chords it ends: asked once.
let mut on_pole: Vec<Option<bool>> = vec![None; n];
let mut node_pole = |k: usize, q: [f64; 2]| -> Result<bool, super::Unclassified> {
if let Some(p) = on_pole.get(k).copied().flatten() {
return Ok(p);
}
let p = pole(q)?;
if let Some(slot) = on_pole.get_mut(k) {
*slot = Some(p);
}
Ok(p)
};
for k in 0..n {
// Proven: n = l.uv.len() >= 3, checked above
let next = (k + 1) % n;
let (Some(&a), Some(&b)) = (l.uv.get(k), l.uv.get(next)) else {
let (Some(&a), Some(&b)) = (l.uv.get(k), l.uv.get((k + 1) % n)) else {
continue;
};
if !(a.iter().chain(&b).all(|x| x.is_finite()))
|| node_pole(k, a)?
|| node_pole(next, b)?
{
if !(a.iter().chain(&b).all(|x| x.is_finite())) || pole(a)? || pole(b)? {
continue;
}
// The loop's closing chord of a loop that winds round the surface ends at the copy of
+26 -37
View File
@@ -155,17 +155,31 @@ fn ok<T>(r: Result<T>) -> T {
const PI: f64 = std::f64::consts::PI;
/// A plane is exact for any triangulation of its boundary: it asks for no size, a box's edges
/// are whole, and each face is its four corners, two triangles. Whatever the box's size.
#[test]
fn a_box_is_twelve_triangles_whatever_its_size() {
for name in ["box_10x6x4", "box_40x30x20"] {
let b = fixture(name);
let (m, s) = plan_body(&b, 0.05);
assert_eq!(s.planar, 6, "{name}");
assert_watertight(&m, name);
assert_eq!(m.triangles.len(), 12, "{name}");
/// Smallest corner angle (degrees) over the mesh.
fn min_angle(m: &Mesh) -> f64 {
let mut least = 180.0f64;
for t in &m.triangles {
let [a, b, c] = t.map(|i| m.positions[i as usize]);
for (p, q, r) in [(a, b, c), (b, c, a), (c, a, b)] {
let (u, v) = (q - p, r - p);
if let (Some(u), Some(v)) = (u.normalized(), v.normalized()) {
least = least.min(u.dot(v).clamp(-1.0, 1.0).acos().to_degrees());
}
}
}
least
}
#[test]
fn a_box_is_a_regular_grid_on_every_face() {
// 40 × 30 × 20: sides at most 1/5 of the diagonal (10.77 mm), so 4 × 3 × 2 cells.
let b = fixture("box_40x30x20");
let (m, s) = plan_body(&b, 0.05);
assert_eq!(s.planar, 6);
assert_watertight(&m, "box");
let cells = 2 * (4 * 3 + 4 * 2 + 3 * 2);
assert_eq!(m.triangles.len(), 2 * cells, "two triangles per grid cell");
assert!(min_angle(&m) > 40.0, "{}", min_angle(&m));
}
#[test]
@@ -1331,16 +1345,7 @@ fn sag_refinement_across_an_inflection_ends_without_slivers() {
let fixed: Vec<Vec3> = Vec::new();
let tol = 0.5;
let start = uv.len();
sag::refine(
&s,
tol,
&mut sag::Poles::new(&s, 1e-9),
&fixed,
&mut uv,
&mut tris,
20_000,
)
.unwrap();
sag::refine(&s, tol, 1e-9, &fixed, &mut uv, &mut tris, 20_000).unwrap();
assert!(
uv.len() - start < 1_000,
"{} points added: refinement ran to its budget",
@@ -1395,7 +1400,7 @@ fn triangles_standing_across_a_curved_sheet_are_flipped_along_it() {
.sum()
};
let before = area(&tris);
sag::flip(&s, 0.1, &mut sag::Poles::new(&s, 1e-9), &[], &uv, &mut tris).unwrap();
sag::flip(&s, 0.1, 1e-9, &[], &uv, &mut tris).unwrap();
let after = area(&tris);
let sides: Vec<(u32, u32)> = tris
.iter()
@@ -2133,19 +2138,3 @@ fn an_edge_stopped_at_its_depth_bound_is_named_within_its_tolerance() {
assert!(capped.within && capped.sag <= capped.tol, "{capped:?}");
assert!(capped.excess > 1.0, "{capped:?}");
}
/// Refining a spline face moves no triangle count: the memo of the pole classifier only drops the
/// repeated question (the coil took 1,089,434 / 1,091,452 calls at display / export before it).
#[test]
fn the_spline_coil_keeps_its_triangles() {
let name = "smlib_coil_lower_half";
let (b, size) = (fixture(name), size_of(name));
for (t, want) in [
(MeshTolerance::display_of(size), 30_630),
(MeshTolerance::export_of(size), 30_662),
] {
let (p, _) = plan(&b, &t, size).unwrap();
let (m, _) = p.evaluate();
assert_eq!(m.triangles.len(), want);
}
}
-2
View File
@@ -25,7 +25,6 @@
//! | `FilletBrep::calc_join_section` | `SmFilletVertex::CalcJoinSection` (543d25a) |
//! | `FilletBrep::calc_rail_int_end_surface` | `SmFilletVertex::CalcRailIntEndSurface` (`SmBlendCornerEnd.cpp`, 3e26f76) |
//! | [`mixed`] | the Mixed corner (`ClassifyMixed`, `MakeMixedTopology`, `CalcMixedVertGeom`, `CalcMixedEdgeGeom`, `CreateMixedPatches`; 6fd0411, 6e79de5, e1a2153) |
//! | [`on_face`] | none: a Setback's spring curve kept inside its trimmed face (solidcore#2343 (d)) |
//! | [`setback`] | the Setback (`ClassifySetBack`, `MakeSetBackTopology`, `BuildSetBackFill`, `CreateSetBackPatches`; 61547af, 1506e2f, f1d81f4) |
//! | [`swept`] | the Swept turn (`Classify`'s Swept part, `BuildSweptSurface`, `CreateSweptPatch`) |
//! | [`BlendChamferCorner`], [`chamfer`] | not the fork's: chamfers meeting at a vertex, closed by their planes (WR-716, #2345) |
@@ -70,7 +69,6 @@ pub mod ball;
pub mod chamfer;
pub mod end;
pub mod mixed;
pub mod on_face;
pub mod setback;
pub mod swept;
+1 -2
View File
@@ -1423,8 +1423,7 @@ pub fn calc_mixed_edge_geom(
Point3::new(to.0, to.1, 0.0),
)?;
let side_i = *nth(if turn { &which_t } else { &which }, kk, OP_EDGE)?;
*nth_mut(of2m(&mut side_curves, f).as_mut_slice(), side_i, OP_EDGE)? =
Some(iso.clone());
*nth_mut(of2m(&mut side_curves, f), side_i, OP_EDGE)? = Some(iso.clone());
of2m(&mut state.curves_3d, f).push(Curve::from(iso));
of2m(&mut state.uv_curves, f).push(Curve::from(uv));
of2m(&mut state.orients, f).push(*nth(&orients, kk, OP_EDGE)?);
@@ -1,383 +0,0 @@
//! Whether a Setback's spring curve stays inside its trimmed face: it comes within no gap of
//! any of the face's other edges (solidcore#2343 (d), the owner's ruling B there).
//!
//! Upstream: none. The fork's ruling on smlib#85 part C asked that the spring curve's
//! parameter-plane control triangle `(P^, X^, Q^)` lie inside the trimmed face; that is the
//! convex-hull bound for the curve itself, sound but loose (it refuses setbacks whose spring
//! curve stays on the face). Ruling B on solidcore#2343 checks the curve.
//!
//! | Rust | Upstream |
//! |-------------------|----------|
//! | [`Clearance`] | none |
//! | [`bezier_pieces`] | none |
//! | [`clear_of`] | none |
//!
//! Decided on control points, never at sample points (`docs/definitions-and-certificates.md`
//! §1): a rational Bézier piece with positive weights lies in the convex hull of its
//! control points (Farin, *Curves and Surfaces for CAGD*). Two pieces whose hulls are
//! proved farther apart than the gap and both pieces' rounding (their boxes', or their gap
//! along the line between their centroids: `apart`) come no nearer than the gap. Otherwise the wider of the two is halved (de Casteljau on its
//! homogeneous points) and the halves are tried in turn. A piece halved [`DEPTH`] times is
//! halved no further, and a pair still undecided then, or once [`PAIRS`] pairs have been
//! tried, is [`Clearance::Near`]: an undecided spring curve is not taken as clear. The
//! rounding is Higham's `gamma` of the operations each point has been through, as
//! `blend_setback::containment` bounds its patches.
//!
//! Deviations: no recursion: the pairs are kept on an explicit stack, each piece halved at
//! most [`DEPTH`] times, at most [`PAIRS`] pairs per curve and piece list.
use std::borrow::Cow;
use crate::curve::curve::{Curve, CurveKind};
use crate::error::Result;
use crate::geom::vector3d::sqrt;
use crate::kernel::Kernel;
use crate::math::Vec3;
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::math::{gamma, NOW, NOZ};
const OP: &str = "blend::blend_corner::on_face::clear_of";
/// A homogeneous point `(x w, y w, z w, w)`.
pub type Hom = [f64; 4];
/// How many times a piece is halved at most: a piece of a part's size, tens of millimetres,
/// is then under 1e-10 long, the rounding of the part's coordinates (§4.4 of the
/// definitions doc).
pub const DEPTH: usize = 40;
/// How many pairs of pieces [`clear_of`] tries at most for one curve against one list of
/// pieces. Near a closest approach a few pairs stay at each halving, so a decided case needs
/// a few hundred.
pub const PAIRS: usize = 4096;
/// Whether one curve comes within a gap of another ([`clear_of`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Clearance {
/// Proved to stay farther than the gap from the other everywhere.
Clear,
/// Within the gap somewhere, or not shown not to be within [`DEPTH`] and [`PAIRS`].
Near,
}
/// The rational Bézier pieces of `curve` as homogeneous points, one piece per knot span: of
/// the B-spline a line, a conic or a B-spline curve is built on (the whole of it, which holds
/// an edge it is trimmed to; a line's or an ellipse's made on a copy where it holds none
/// yet). `None` for a curve of another kind, a planar (2D) one, one whose B-spline is not
/// made, or one whose weights are not all positive and finite: such a curve has no hull to
/// bound it by.
///
/// # Errors
///
/// Those of the B-spline's decomposition into its Bézier pieces; a cancellation while an
/// ellipse's is made.
pub fn bezier_pieces(k: &Kernel, curve: &Curve) -> Result<Option<Vec<Vec<Hom>>>> {
let base: Cow<'_, BSplineCurve> = match curve.kind() {
CurveKind::BSpline(c) => Cow::Borrowed(c),
CurveKind::Line(c) => c.base_with_nurb()?,
CurveKind::Ellipse(c) if c.base().nurb.is_none() => {
let mut copy = c.clone();
match copy.make_nurb(k) {
Err(e) if e.is_unported_or_cancelled() => return Err(e),
Err(_) => return Ok(None),
Ok(()) => Cow::Owned(copy.base().clone()),
}
}
CurveKind::Ellipse(c) => Cow::Borrowed(c.base()),
CurveKind::Hyperbola(c) => Cow::Borrowed(&c.base),
CurveKind::Parabola(c) => Cow::Borrowed(c.base()),
_ => return Ok(None),
};
let Some(nurb) = base.nurb.as_ref() else {
return Ok(None);
};
let mut pieces = Vec::new();
for bez in nurb.decompose_bez()? {
let mut piece = Vec::new();
for cp in bez.cpts() {
if cp.z == NOZ {
return Ok(None);
}
let h = if cp.w == NOW {
[cp.x, cp.y, cp.z, 1.0]
} else {
[cp.x, cp.y, cp.z, cp.w]
};
if !h.iter().all(|c| c.is_finite()) || h[3] <= 0.0 {
return Ok(None);
}
piece.push(h);
}
pieces.push(piece);
}
Ok(Some(pieces))
}
/// A piece on the stack: its homogeneous points, how many more times it may be halved, and
/// the rounding bound of its Euclidean points (that of the piece it was halved from).
#[derive(Clone)]
struct Piece {
points: Vec<Hom>,
depth: usize,
rounding: f64,
}
impl Piece {
/// A piece as given: its Euclidean points are within `rounding` of the exact ones after
/// [`DEPTH`] halvings of `degree` averaging steps each and the division by the weight:
/// `3 gamma(ops + 2)` of its largest homogeneous coordinate over its least weight (shrunk
/// by the weights' own rounding), `ops = 8 + DEPTH degree` (the 8 for the knot insertions
/// that cut a B-spline into its pieces and the division). `None` where a weight is not
/// positive or a coordinate not finite.
fn new(points: &[Hom]) -> Option<Self> {
let degree = points.len().checked_sub(1)?;
let (mut most, mut w_min) = (0.0f64, f64::INFINITY);
for h in points {
if !h.iter().all(|c| c.is_finite()) {
return None;
}
most = most.max(h[0].abs()).max(h[1].abs()).max(h[2].abs());
w_min = w_min.min(h[3]);
}
let ops = 8 + DEPTH * degree;
let shrink = 1.0 - gamma(ops);
if !(w_min > 0.0 && shrink > 0.0) {
return None;
}
Some(Self {
points: points.to_vec(),
depth: DEPTH,
rounding: 3.0 * gamma(ops + 2) * most / (w_min * shrink),
})
}
/// Its Euclidean points; `None` where a weight is not positive.
fn euclidean(&self) -> Option<Vec<Vec3>> {
let mut out = Vec::new();
for h in &self.points {
if h[3].is_nan() || h[3] <= 0.0 {
return None;
}
out.push(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]));
}
Some(out)
}
/// Its two halves at the middle of its parameter (de Casteljau), each halvable once less.
fn halves(&self) -> (Self, Self) {
let d = self.points.len().saturating_sub(1);
let mut work = self.points.clone();
let mut left = Vec::new();
let mut right = Vec::new();
for k in 0..=d {
if let Some(&h) = work.first() {
left.push(h);
}
if let Some(&h) = work.get(d - k) {
right.push(h);
}
for i in 0..d - k {
if let (Some(&a), Some(&b)) = (work.get(i), work.get(i + 1)) {
if let Some(w) = work.get_mut(i) {
*w = [
0.5 * (a[0] + b[0]),
0.5 * (a[1] + b[1]),
0.5 * (a[2] + b[2]),
0.5 * (a[3] + b[3]),
];
}
}
}
}
right.reverse();
let depth = self.depth.saturating_sub(1);
(
Self {
points: left,
depth,
rounding: self.rounding,
},
Self {
points: right,
depth,
rounding: self.rounding,
},
)
}
}
/// The widest extent of `points` along an axis.
fn width(points: &[Vec3]) -> f64 {
let (lo, hi) = extent(points);
(hi.x - lo.x).max(hi.y - lo.y).max(hi.z - lo.z)
}
/// The box of `points`, its least and greatest corners.
fn extent(points: &[Vec3]) -> (Vec3, Vec3) {
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in points {
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
(lo, hi)
}
/// A lower bound on the distance between the convex hulls of `a` and `b`, less its own
/// rounding: the larger of the distance between their boxes and their gap along the line
/// between their centroids (any unit direction `u` separates them by `min u . b - max u . a`;
/// that one closes on the hulls' distance as near-parallel pieces shrink, where the boxes'
/// does not). The boxes' gaps are rounded by `2 gamma(1)` of the largest coordinate `s`, their
/// sum of squares by `gamma(6)`; each projection by `gamma(3) sqrt 3 s |u|`, and the quotient
/// by `|u|` by `gamma(4)`.
fn apart(a: &[Vec3], b: &[Vec3]) -> f64 {
let ((alo, ahi), (blo, bhi)) = (extent(a), extent(b));
let size = [alo, ahi, blo, bhi].iter().fold(0.0f64, |m, c| {
m.max(c.x.abs()).max(c.y.abs()).max(c.z.abs())
});
let gap = |l: f64, h: f64| l - h;
let mut squares = 0.0;
for d in [
gap(blo.x, ahi.x).max(gap(alo.x, bhi.x)),
gap(blo.y, ahi.y).max(gap(alo.y, bhi.y)),
gap(blo.z, ahi.z).max(gap(alo.z, bhi.z)),
] {
let d = (d - 2.0 * gamma(1) * size).max(0.0);
squares += d * d;
}
let boxes = sqrt(squares * (1.0 - gamma(6))) * (1.0 - gamma(2));
let centroid = |p: &[Vec3]| {
let (sum, count) = p
.iter()
.fold((Vec3::ZERO, 0.0), |(s, c), &q| (s + q, c + 1.0));
sum * (1.0 / count)
};
let w = centroid(b) - centroid(a);
// the line between the centroids, and its parts square to each piece's chord (for
// near-parallel pieces side by side, whatever their shift along each other)
let square_to = |p: &[Vec3]| match (p.first(), p.last()) {
(Some(&first), Some(&last)) => {
let t = last - first;
let tt = t.dot(t);
if tt > 0.0 {
w - t * (w.dot(t) / tt)
} else {
w
}
}
_ => w,
};
let mut best = boxes;
for u in [w, square_to(a), square_to(b)] {
let length = u.length();
if length.is_nan() || length <= 0.0 {
continue;
}
let reach_a = a.iter().fold(f64::NEG_INFINITY, |m, p| m.max(u.dot(*p)));
let reach_b = b.iter().fold(f64::INFINITY, |m, p| m.min(u.dot(*p)));
let along = (reach_b - reach_a) / length * (1.0 - gamma(4))
- 2.0 * gamma(3) * sqrt(3.0) * size * (1.0 + gamma(4));
best = best.max(along);
}
best
}
/// Whether the rational Bézier curve `curve` (its homogeneous Bernstein coefficients) comes
/// within `gap` of any of `pieces` (each such a curve too): [`Clearance::Clear`] only where
/// that is proved on the pieces' control points (module docs).
///
/// # Errors
///
/// The kernel's cancellation.
pub fn clear_of(k: &Kernel, curve: &[Hom], pieces: &[Vec<Hom>], gap: f64) -> Result<Clearance> {
let Some(a) = Piece::new(curve) else {
return Ok(Clearance::Near);
};
let mut stack = Vec::new();
for p in pieces {
let Some(b) = Piece::new(p) else {
return Ok(Clearance::Near);
};
stack.push((a.clone(), b));
}
let mut tried = 0;
while let Some((a, b)) = stack.pop() {
k.check_cancelled(OP)?;
tried += 1;
if tried > PAIRS {
return Ok(Clearance::Near);
}
let (Some(pa), Some(pb)) = (a.euclidean(), b.euclidean()) else {
return Ok(Clearance::Near);
};
if apart(&pa, &pb) > gap + a.rounding + b.rounding {
continue;
}
let (wa, wb) = (width(&pa), width(&pb));
// the wider is halved, where it may be; neither: undecided
let halve_a = a.depth > 0 && (wa >= wb || b.depth == 0);
if halve_a {
let (a0, a1) = a.halves();
stack.push((a1, b.clone()));
stack.push((a0, b));
} else if b.depth > 0 {
let (b0, b1) = b.halves();
stack.push((a.clone(), b1));
stack.push((a, b0));
} else {
return Ok(Clearance::Near);
}
}
Ok(Clearance::Clear)
}
#[cfg(test)]
mod tests {
use super::*;
/// A quarter circle of radius `r` about `(cx, cy)` in z = 0, from angle 0 to 90 degrees:
/// the rational quadratic with middle weight `cos 45`.
fn quarter(cx: f64, cy: f64, r: f64) -> Vec<Hom> {
let w = 0.5f64.sqrt();
vec![
[cx + r, cy, 0.0, 1.0],
[(cx + r) * w, (cy + r) * w, 0.0, w],
[cx, cy + r, 0.0, 1.0],
]
}
/// A segment from `a` to `b`, degree 1.
fn segment(a: [f64; 3], b: [f64; 3]) -> Vec<Hom> {
vec![[a[0], a[1], a[2], 1.0], [b[0], b[1], b[2], 1.0]]
}
#[test]
fn a_segment_near_a_circle_is_clear_by_more_than_the_gap_and_near_within_it() {
let k = Kernel::default();
let arc = vec![quarter(0.0, 0.0, 10.0)];
// the line x + y = c outside the circle is closest to the arc at 45 degrees, c / sqrt 2
// - 10 away, and runs along it within twice that for a hundredth of a turn
let c = 2.0f64.sqrt() * (10.0 + 1e-3);
let chord = segment([c, 0.0, 0.0], [0.0, c, 0.0]);
assert_eq!(
clear_of(&k, &chord, &arc, 0.9e-3).unwrap(),
Clearance::Clear
);
assert_eq!(clear_of(&k, &chord, &arc, 1.1e-3).unwrap(), Clearance::Near);
// inside it, crossing it twice: near at any gap
let c = 2.0f64.sqrt() * (10.0 - 1e-3);
let chord = segment([c, 0.0, 0.0], [0.0, c, 0.0]);
assert_eq!(clear_of(&k, &chord, &arc, 0.0).unwrap(), Clearance::Near);
}
#[test]
fn pieces_far_apart_are_clear_at_once_and_a_bad_weight_is_near() {
let k = Kernel::default();
let far = vec![quarter(100.0, 0.0, 1.0)];
let chord = segment([0.0, 0.0, 0.0], [1.0, 1.0, 0.0]);
assert_eq!(clear_of(&k, &chord, &far, 1e-6).unwrap(), Clearance::Clear);
let mut bad = quarter(100.0, 0.0, 1.0);
bad[1][3] = 0.0;
assert_eq!(clear_of(&k, &chord, &[bad], 1e-6).unwrap(), Clearance::Near);
}
}

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