Compare commits

...

33 Commits

Author SHA1 Message Date
Brennen Herbruck a50ad60ad0 AGENTS.md: keep the layers.md pointer and the plan/ note; app-geometry in the layers skill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:50:15 -05:00
Brennen Herbruck a531623d2d xtask: add cargo xtask app-geometry, a shrink-only ledger of geometry in the apps
Lists the functions in doc, engine, ui-egui, io, kernel and geom that do vector
maths, solves or mesh queries (1,256 today). The check fails on a function that is
not listed and on a listed one that is gone or no longer computes geometry, and
prints the count per crate. Runs in CI beside the layers check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:46:52 -05:00
jyviko 92af38e470 Start page: a component that takes the host's entries and icons and returns the entry chosen (#13410)
* 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>

* Start page: the page is a component that returns the entry chosen (#12941)

start_page::show draws the rail, the title and one body (action tiles,
recent files and samples; a tile grid; a review list) from plain data:
labels, icon names, host thumbnails, and what each entry stands for.
It returns the entry clicked; it holds no app, design or engine type.
home.rs is SolidCraft's use of it: it builds the page from the app's
state and runs the chosen FileAction. Recent, menu and sample clicks now
go through dispatch after the frame instead of running mid-draw.
Every published home rect and handle is unchanged (dumped before/after).

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

* Start page: tiles and samples take a host Icon (#12941)

ui_kit::Icon is the picture a host gives a kit surface: a kit icon
name, a texture it made, or its own painter. FileView's action tiles and
sample cards draw it, so the start page shows another app's icons.
start_page is a public module like view_compass; Body::Start is boxed
(it is far larger than the other bodies). Tests: a tile draws the host's
texture icon, a recent row's context menu returns the entry picked, a
disabled rail entry returns nothing.

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

* Home: start-page entries name a recovery entry or recent file by index (#12941)

FileAction::Recovery, OpenRecent, ForgetRecent and ShowInFolder carry
an index into the app's lists instead of a cloned entry or path, and
the page borrows titles and file names from the app, so drawing the
start page no longer clones an entry or a path per frame. dispatch looks
the index up once, when the entry is chosen.

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

* Start page: a menu entry's icon is a kit name until MenuItem takes an Icon (#12941)

Recent rows' context-menu entries are drawn by ui_kit::MenuItem, whose
icon is a kit icon name; #13379 gives it a host Icon.

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-09 01:53:04 +00:00
PeppaWutz 0c4f55b60f Agents and scripts: Insert Mesh describes its exact parameters and reply (#13309) (#13341)
* Agents and scripts: Insert Mesh describes its exact parameters and reply (#13309)

file.insert_mesh now publishes typed input and output schemas. Each
parameter is read the way the handler reads it today and refused with the
handler's own message, and the reply is built from the same type its output
schema describes, so the JSON it returns does not change. Keys the handler
reads only in some cases (up only for a glTF file, names and folder only when
up is given for a glTF file, everything but path only for a mesh file) are
read by the check in the same cases only. Its line leaves unconverted.txt.

Part of #2829.

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

* Insert Mesh schema: leave name unread for a file that is not a mesh

The handler refuses the kind of file before it reads name, so Strict now
does too.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-09 01:47:26 +00:00
github-actions[bot] 0d490ffb35 Regenerate generated files and fold pending files [skip ci] 2026-11-09 01:50:34 +00:00
bherbruck b5716d0fb8 Dial CodeRabbit down: chill profile, no draft or per-push reviews, no extras (#13889)
* Dial CodeRabbit down: chill profile, no draft or per-push reviews, no extras

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

* CodeRabbit: the defaults rule is about UX defaults only, not kernel results

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

* CodeRabbit: define a changed default with the owner's examples

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:58:54 +00:00
bherbruck dab1f296bb Docs: Changes a default means UX defaults only (#13927)
* Docs: Changes a default means UX defaults only, not kernel results

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

* Docs: sharper definition of a changed default

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

* AGENTS.md: back under the 5,000-character cap

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:58:46 +00:00
PeppaWutz d946550019 Agents and scripts: Align describes its exact parameters and reply (#13314) (#13342)
* Agents and scripts: Align describes its exact parameters and reply (#13314)

solid.align now publishes typed input and output schemas, so agents and
scripts can read exactly which parameters it takes and what it returns,
and the warn and strict parameter checks cover it.

Each parameter is read the way the handler reads it today, with the same
error messages, and only in the cases where the handler reads it: a side
that has a plane or a frame, or a face given as a point, does not check the
keys the handler skips. The reply is the same feature result as before. Its
line leaves unconverted.txt. Part of #2829.

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

* Align schema: leave a frame unread beside the side's other keys

The handler refuses a frame given with the side's point, face or plane as
"not both" before it reads the frame, so Strict now does too.

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

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

* 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).

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 00:50:54 +00:00
bherbruck 47121d5287 Agent chat: a PDF's text goes to agents that take no documents (#2812) (#2860)
The PDF is read with lopdf (MIT, native builds only) on the worker that reads the file: the first
300 pages and 200,000 characters, with a cap on what a page's content may inflate to and a catch
for a reader panic. Text-less (scanned) or unreadable PDFs are pointed to by path.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 00:35:48 +00:00
DuBearte be12f10eab MCP: a compact sketch reply counts spline poles instead of listing them (#9180) (#9232)
With verbose: false, a sketch command that creates more points than its
new curves have ends (the poles of an offset's or a fitted spline)
replies created.point_count instead of every point id. Agents refer to
those curves by id, so the ids only filled their context: an offset of a
16-curve outline listed 117 to 144 of them. Rectangles, lines and arcs
keep their point ids.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:35:32 +00:00
HiroKenEz 39e4100773 Appearance: the theme row opens on the theme in use each time Preferences shows it (#11445) (#12026)
* claim: #11445

* Appearance: the theme row opens on the theme in use each time it comes into view (#11445)

The theme row already brought the theme in use into view when the theme
or a filter changed. It now does so as well each time the row comes back
into view: Preferences opens, its Appearance page is shown again, or a
search is cleared. Only then: the arrows, the wheel and a drag move the
row freely while it stays in view.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-09 00:35:12 +00:00
thocsmith 6ca597b857 Browser: Send to Slicer and Save As Mesh take every selected body (#12334) (#12368)
* claim: #12334

* Browser: Send to Slicer and Save As Mesh take every selected body (#12334)

Right-clicking a Browser body that is one of several selected bodies opened the mesh dialogs with only that body. They now start with every selected body, as the Appearance item on the same menu already does; a body outside the selection still opens them with itself alone.

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

---------

Co-authored-by: thocsmith <193096300+thocsmith@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-09 00:34:59 +00:00
P'tite Tête 543314c191 fix: A revolve axis without an origin passes through the world origin in a moved component (#12615)
- the frame table's axis mapping gives an {origin, dir} axis without origin the world's [0, 0, 0] before mapping it
- test: solid and surface revolves, made and edited, against origin [0, 0, 0]

Co-authored-by: P'tite Tête <asso@ptitetete.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-09 00:34:33 +00:00
CamWight 8fb4a1d22a CSV exports: a field that starts with =, +, - or @ stays text in a spreadsheet (#12728) (#12729)
* CSV exports: a field that starts with =, +, - or @ stays text in a spreadsheet (#12728)

A spreadsheet reads a CSV field that starts with =, +, - or @ as a formula, so a part numbered
=1+1 opened as 2 and "-5 mm shim" as #NAME?. Every CSV writer now writes such a field with a '
first (OWASP's CSV Injection mitigation), through one module, solidcraft_doc::csv:

- field() quotes only when a field needs it, now also for ; and a tab (a spreadsheet set to ;
  splits there); quoted() always quotes; unguard() takes the ' off again on import.
- A whole plain number (-5, +5, 1e-9) is left as it is: a spreadsheet reads it as that number.
  inf, NaN and a number with spaces around it are not plain numbers.
- Spaces and apostrophes before a formula start count. A field that already starts with ' and
  then a formula gets one more, so parameters.import reads back exactly what was exported.
- The parameter export, the BOM, the bend table and the motion export use it. Each keeps its
  quoting style, so files with plain fields are byte for byte the same, except one: the motion
  export used to turn a comma in a placement's name into a space and broke the row on a quote
  or a line break; the name is now quoted and kept.

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

* Guides: how a script reads a guarded BOM CSV, and older parameter CSVs with a typed '

From @theironchef's review of #12729. The assemblies guide says a script
reading a BOM CSV sees `'=1+1`, and gets the value back by taking off one
leading `'` before a formula start (as `solidcraft_doc::csv::unguard`
does) or by reading the JSON export. The parameters guide says a CSV from
an earlier version whose value really started with a typed `'` before a
formula character (`'=x`) now imports as `=x`. Both pages now name every
formula start the guard uses (the full-width forms, a tab, a line break,
after spaces) and say a plain number such as `-5` is left as it is.

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

* Guides: a guarded CSV field after several apostrophes, and a spreadsheet that saves the file again

A formula field is one that starts as a formula after any spaces and
apostrophes, as `solidcraft_doc::csv` checks it: `'=1+1` is written as
`''=1+1`, and taking off one leading `'` gives it back. Both guides also
say the `'` holds only while the file is as SolidCraft wrote it: a
spreadsheet that saves the file again as CSV can drop it (Excel does), so
the formula runs the next time that file opens, and the advice is to
export again instead of passing on such a file (OWASP's CSV Injection
page says the same of these guards).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:34:13 +00:00
ancient_astronaut_theorist 0fc1ef8ca7 Fillet and Chamfer: when only one of several picked edges is left between two faces, ask for the edges again instead of rounding just one (#13332) (#13423)
* claim: #13332

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

* Ask for reselection when numbered edges' faces now bound one edge elsewhere (#13332)

Several edges between the same two faces share one name and are told
apart by a number (`~k`) given by where they lie. When the faces change
so that only one edge is left between them, every numbered name used to
find that edge: a chamfer of nine edges chamfered one, with no error.

A numbered name now takes the one edge left between its faces only where
its reference's point lies on it. Otherwise the name finds nothing and
its feature asks for its edges to be picked again.

Fixes #13332

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:32:55 +00:00
bherbruck 5fe92e88ec Tapped hole: thread every piece of a wall split by a pocket or slot (#13505) (#13520)
A tapped hole drilled across a pocket or slot leaves its cylindrical wall
in separate faces along the axis. The thread was put on only the piece
nearest the entry, so cosmetic and modelled threads stopped at the first
gap. The hole now threads every piece of the wall within its depth, and a
thread depth runs on through the pieces from where the first one starts.

Fixes #13505

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:32:42 +00:00
github-actions[bot] 1c8fdde4ba Regenerate generated files and fold pending files [skip ci] 2026-11-09 00:54:57 +00:00
PeppaWutz 3ce8b875b1 Agents and scripts: Save As Mesh describes its exact parameters and reply (#13308) (#13353)
* Agents and scripts: Save As Mesh describes its exact parameters and reply (#13308)

file.save_mesh now publishes typed input and output schemas. Each parameter
is read the way the handler reads it today and refused with the handler's own
message, and the reply is built from the same typed values the output schema
describes, so the JSON it returns does not change. `ascii` is read by the
check only with `format: stl`, and `single_instance` only with a `slicer`,
as the handler does. Its line leaves unconverted.txt.

Part of #2829.

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

* Fix unconverted.txt after merge
2026-11-09 00:23:39 +00:00
bherbruck bf78850423 Tests: an open setback corner now leaves a 9-edge hole (#13540) (#13895)
* claim: #13540 and #13542, the setback corner's tests after solidcore #2988

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

* test: the marked setback corner is the Setback; its open hole is nine edges (#13540)

Since tangency-labs/solidcore#2988 (e9a5ded95c) a vertex marked set back between planes is the
blend corner's Setback with its certified fill, not the N x N patch. Its sections and face
curves are one edge each, so the box corner left open has 9 faces, 27 edges and 19 vertices
and a hole of nine edges (the N x N patch split each in two: 12 edges, 30 and 22).

The G2 corner's side to its round is likewise one edge (w2949's smooth_edges change, the test
renamed); it is not drawn whole, so that test stays known-failing with its new cause (#13542).

Fixes tangency-labs/solidcraft#13540

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:15:14 +00:00
0rdnungsamt 3af51db557 Shortcuts: command search can move off S, and S can go to another command (#12190) (#13163)
* claim: #12190

* Shortcuts: command search can move off S, and S can go to another command (#12190)

Command search becomes the ordinary command ui.palette with S as its default
key, so Preferences > Shortcuts lists it and can move it; S leaves the
reserved keys and the hard-wired S branch in the toolbar goes. The search
field's key cap shows command search's current key, or none when it has
none. Out of the box nothing changes: S still opens command search.

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

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 00:14:25 +00:00
Loohi 956fe0070b Revolve: input and output schemas (part of #2829) (#13319)
* claim: part of #2829, revolve schema

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:55:09 +00: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
132 changed files with 10140 additions and 1415 deletions
+17 -5
View File
@@ -1,15 +1,27 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
profile: chill
poem: false
sequence_diagrams: false
related_issues: false
related_prs: false
suggested_labels: false
suggested_reviewers: false
in_progress_fortune: false
auto_review:
# Review when a PR is opened or marked ready, or on `@coderabbitai review`; not on every push.
drafts: false
auto_incremental_review: false
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
path_instructions:
- path: "**"
instructions: |
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
Project rule: don't change anyone's existing UX defaults unless the change is purely additive and belongs in the application.
A changed default is when something people already rely on in the UI changes for everyone who didn't opt in: an existing control, interaction or setting value. Examples the owner declined or reverted: right click becoming select while left click orbits (make it opt-in instead); a PR that removed all camera animation when only orbit lag was to be fixed. Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut.
A changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default; don't flag it as one, and expect `Changes a default: no` for it.
Not defaults: bug fixes and more correct results, kernel or geometry result changes, new options that are off by default, and CI or tooling config. Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
# Where code goes, SolidCraft or SolidCore: solidcore/docs/layers.md.
@@ -177,5 +189,5 @@ reviews:
- name: "Existing defaults unchanged"
mode: "warning"
instructions: |
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
Pass if the pull request changes no UX default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts), or if its changes are purely additive. UX defaults only: a changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default and passes.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes a UX default.
+1 -1
View File
@@ -6,7 +6,7 @@ Start with what this is, in plain words someone new to the project would underst
Changes a default: no
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour; a UX default only, a kernel result change is 'no'):
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
+6 -2
View File
@@ -436,6 +436,10 @@ jobs:
run: |
cargo xtask mesh-decisions
cargo test -p xtask mesh_decisions --locked
- name: Apps compute no geometry
run: |
cargo xtask app-geometry
cargo test -p xtask app_geometry --locked
- name: Agent router and skills cite real files
run: |
cargo xtask skills
@@ -448,8 +452,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);
+2 -2
View File
@@ -12,7 +12,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`; see `solidcore/docs/layers.md`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`/`app-geometry`; `solidcore/docs/layers.md`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
@@ -20,7 +20,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
- A changed default (existing UI, not kernel results) keeps the old behaviour behind a preference; fill the PR's `Changes a default:` line.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
+26 -9
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,9 +442,13 @@ 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).
`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). A default here is a UX default only: a control, setting, preference or tool behaviour a user sees by default; a changed kernel or geometry result is `Changes a default: no`.
## Labels and triage
Generated
+178 -12
View File
@@ -125,10 +125,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -143,6 +154,21 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -485,6 +511,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -516,6 +551,16 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -703,7 +748,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
]
[[package]]
@@ -768,7 +822,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
"inout 0.1.4",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.6",
]
[[package]]
@@ -887,6 +951,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -996,9 +1066,9 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"aes 0.8.4",
"block-padding 0.3.3",
"cbc 0.1.2",
"dbus",
"fastrand",
"hkdf",
@@ -1095,6 +1165,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecb"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ecolor"
version = "0.37.0"
@@ -1625,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
"weezl 0.1.12",
]
[[package]]
@@ -2068,10 +2147,20 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"block-padding 0.3.3",
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2316,6 +2405,43 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lopdf"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
dependencies = [
"aes 0.9.4",
"bitflags 2.13.2",
"brotli-decompressor",
"cbc 0.2.1",
"ecb",
"encoding_rs",
"flate2",
"getrandom 0.4.3",
"indexmap",
"itoa",
"log",
"md-5",
"nom",
"rand 0.10.3",
"rangemap",
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -3311,6 +3437,7 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@@ -3355,6 +3482,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "rangemap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3730,8 +3863,8 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"hkdf",
@@ -4333,7 +4466,7 @@ dependencies = [
"solidcraft-color",
"solidcraft-geom",
"web-time",
"weezl",
"weezl 0.1.12",
]
[[package]]
@@ -4402,6 +4535,7 @@ dependencies = [
"egui-wgpu",
"image",
"log",
"lopdf",
"minisign-verify",
"muda",
"nix 0.31.3",
@@ -4506,6 +4640,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4629,7 +4774,7 @@ dependencies = [
"flate2",
"half",
"quick-error",
"weezl",
"weezl 0.1.12",
"zune-jpeg",
]
@@ -4668,6 +4813,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
@@ -4845,6 +4996,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.5"
@@ -5226,6 +5386,12 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "weezl"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
[[package]]
name = "wgpu"
version = "30.0.1"
+570 -111
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 540 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 559 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -14,7 +14,10 @@ accesskit_windows 0.35.2 (MIT OR Apache-2.0)
accesskit_winit 0.34.2 (Apache-2.0)
adler2 2.0.1 (0BSD OR MIT OR Apache-2.0)
aes 0.8.4 (MIT OR Apache-2.0)
aes 0.9.4 (MIT OR Apache-2.0)
ahash 0.8.12 (MIT OR Apache-2.0)
alloc-no-stdlib 2.0.4 (BSD-3-Clause)
alloc-stdlib 0.2.4 (BSD-3-Clause)
allocator-api2 0.2.21 (MIT OR Apache-2.0)
android-activity 0.6.1 (MIT OR Apache-2.0)
android-properties 0.2.2 (MIT)
@@ -47,9 +50,11 @@ bitflags 2.13.2 (MIT OR Apache-2.0)
block-buffer 0.10.4 (MIT OR Apache-2.0)
block-buffer 0.12.1 (MIT OR Apache-2.0)
block-padding 0.3.3 (MIT OR Apache-2.0)
block-padding 0.4.2 (MIT OR Apache-2.0)
block2 0.5.1 (MIT)
block2 0.6.2 (MIT)
blocking 1.7.0 (Apache-2.0 OR MIT)
brotli-decompressor 5.0.3 (BSD-3-Clause/MIT)
bumpalo 3.20.3 (MIT OR Apache-2.0)
bytemuck 1.25.2 (Zlib OR Apache-2.0 OR MIT)
bytemuck_derive 1.12.1 (Zlib OR Apache-2.0 OR MIT)
@@ -66,10 +71,13 @@ cadmpeg-registry 0.6.7 (Apache-2.0)
calloop 0.13.0 (MIT)
calloop-wayland-source 0.3.0 (MIT)
cbc 0.1.2 (MIT OR Apache-2.0)
cbc 0.2.1 (MIT OR Apache-2.0)
cfg-if 1.0.5 (MIT OR Apache-2.0)
cgl 0.3.2 (MIT / Apache-2.0)
chacha20 0.10.2 (MIT OR Apache-2.0)
chardetng 0.1.17 (Apache-2.0 OR MIT)
cipher 0.4.4 (MIT OR Apache-2.0)
cipher 0.5.2 (MIT OR Apache-2.0)
clipboard-win 5.4.1 (BSL-1.0)
codespan-reporting 0.13.1 (Apache-2.0)
color 0.3.3 (Apache-2.0 OR MIT)
@@ -83,6 +91,7 @@ core-foundation-sys 0.8.7 (MIT OR Apache-2.0)
core-graphics 0.23.2 (MIT OR Apache-2.0)
core-graphics-types 0.1.3 (MIT OR Apache-2.0)
core_detect 1.0.0 (MIT/Apache-2.0)
cpubits 0.1.1 (MIT OR Apache-2.0)
cpufeatures 0.2.17 (MIT OR Apache-2.0)
cpufeatures 0.3.1 (MIT OR Apache-2.0)
crc32fast 1.5.2 (MIT OR Apache-2.0)
@@ -106,6 +115,7 @@ document-features 0.2.12 (MIT OR Apache-2.0)
downcast-rs 1.2.1 (MIT/Apache-2.0)
dpi 0.1.2 (Apache-2.0 AND MIT)
dyn-clone 1.0.20 (MIT OR Apache-2.0)
ecb 0.2.1 (MIT OR Apache-2.0)
ecolor 0.37.0 (MIT OR Apache-2.0)
eframe 0.37.0 (MIT OR Apache-2.0)
egui 0.37.0 (MIT OR Apache-2.0)
@@ -192,6 +202,7 @@ image-webp 0.2.4 (MIT OR Apache-2.0)
indexmap 2.14.2 (Apache-2.0 OR MIT)
inflections 1.1.1 (MIT)
inout 0.1.4 (MIT OR Apache-2.0)
inout 0.2.6 (MIT OR Apache-2.0)
itertools 0.15.0 (MIT OR Apache-2.0)
itoa 1.0.18 (MIT OR Apache-2.0)
jni 0.22.4 (MIT OR Apache-2.0)
@@ -217,6 +228,8 @@ litemap 0.8.3 (Unicode-3.0)
litrs 1.0.0 (MIT OR Apache-2.0)
lock_api 0.4.14 (MIT OR Apache-2.0)
log 0.4.34 (MIT OR Apache-2.0)
lopdf 0.45.0 (MIT)
md-5 0.11.0 (MIT OR Apache-2.0)
memchr 2.8.3 (Unlicense OR MIT)
memmap2 0.9.11 (MIT OR Apache-2.0)
memoffset 0.9.1 (MIT)
@@ -311,11 +324,13 @@ quick-xml 0.41.0 (MIT)
quote 1.0.47 (MIT OR Apache-2.0)
r-efi 5.3.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
r-efi 6.0.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
rand 0.10.3 (MIT OR Apache-2.0)
rand 0.8.8 (MIT OR Apache-2.0)
rand_chacha 0.3.1 (MIT OR Apache-2.0)
rand_core 0.10.1 (MIT OR Apache-2.0)
rand_core 0.6.4 (MIT OR Apache-2.0)
range-alloc 0.1.5 (MIT OR Apache-2.0)
rangemap 1.8.0 (MIT/Apache-2.0)
raw-window-handle 0.6.2 (MIT OR Apache-2.0 OR Zlib)
raw-window-metal 1.1.0 (MIT OR Apache-2.0)
rayon 1.12.0 (MIT OR Apache-2.0)
@@ -387,6 +402,7 @@ spirv 0.4.0+sdk-1.4.341.0 (Apache-2.0)
stable_deref_trait 1.2.1 (MIT OR Apache-2.0)
static_assertions 1.1.0 (MIT OR Apache-2.0)
strict-num 0.1.1 (MIT)
stringprep 0.1.5 (MIT/Apache-2.0)
subtle 2.6.1 (BSD-3-Clause)
syn 2.0.119 (MIT OR Apache-2.0)
syn 3.0.6 (MIT OR Apache-2.0)
@@ -402,6 +418,7 @@ tiff 0.11.3 (MIT)
tiny-skia 0.12.0 (BSD-3-Clause)
tiny-skia-path 0.12.0 (BSD-3-Clause)
tinystr 0.8.4 (Unicode-3.0)
tinyvec 1.13.3 (Zlib OR Apache-2.0 OR MIT)
tokio 1.53.2 (MIT)
toml 1.1.8+spec-1.1.0 (MIT OR Apache-2.0)
toml_datetime 1.1.2+spec-1.1.0 (MIT OR Apache-2.0)
@@ -422,6 +439,7 @@ uds_windows 1.2.1 (MIT)
unicode-bidi 0.3.18 (MIT OR Apache-2.0)
unicode-general-category 1.1.0 (Apache-2.0)
unicode-ident 1.0.26 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-normalization 0.1.26 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-properties 0.1.5 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-segmentation 1.13.3 (MIT OR Apache-2.0)
unicode-width 0.2.2 (MIT OR Apache-2.0)
@@ -455,6 +473,7 @@ web-time 1.1.0 (MIT OR Apache-2.0)
webbrowser 1.2.4 (MIT OR Apache-2.0)
webpki-root-certs 1.0.10 (CDLA-Permissive-2.0)
weezl 0.1.12 (MIT OR Apache-2.0)
weezl 0.2.1 (MIT OR Apache-2.0)
wgpu 30.0.1 (MIT OR Apache-2.0)
wgpu-core 30.0.1 (MIT OR Apache-2.0)
wgpu-core-deps-apple 30.0.1 (MIT OR Apache-2.0)
@@ -844,10 +863,11 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
aes 0.8.4, block-buffer 0.10.4, block-buffer 0.12.1, block-padding 0.3.3, cbc 0.1.2, cipher 0.4.4,
const-oid 0.10.2, cpufeatures 0.2.17, cpufeatures 0.3.1, crypto-common 0.1.7, crypto-common 0.2.2,
digest 0.10.7, digest 0.11.3, hkdf 0.12.4, hmac 0.12.1, hybrid-array 0.4.15, inout 0.1.4, sha1
0.10.7, sha1 0.11.0, sha2 0.10.9, sha2 0.11.1
aes 0.8.4, aes 0.9.4, block-buffer 0.10.4, block-buffer 0.12.1, block-padding 0.3.3, block-padding
0.4.2, cbc 0.1.2, cbc 0.2.1, chacha20 0.10.2, cipher 0.4.4, cipher 0.5.2, const-oid 0.10.2, cpubits
0.1.1, cpufeatures 0.2.17, cpufeatures 0.3.1, crypto-common 0.1.7, crypto-common 0.2.2, digest
0.10.7, digest 0.11.3, ecb 0.2.1, hkdf 0.12.4, hmac 0.12.1, hybrid-array 0.4.15, inout 0.1.4, inout
0.2.6, md-5 0.11.0, sha1 0.10.7, sha1 0.11.0, sha2 0.10.9, sha2 0.11.1
================================================================================
Apache License
@@ -1052,6 +1072,37 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
aes 0.9.4
================================================================================
Copyright (c) 2018-2024 The RustCrypto Project Developers
Copyright (c) 2018 Artyom Pavlov
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
ahash 0.8.12
================================================================================
@@ -1105,11 +1156,11 @@ plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1
1.2.0, secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0,
security-framework-sys 2.17.0, send_wrapper 0.6.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0,
smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, tempfile 3.27.0, ttf-parser 0.25.1,
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2, url
2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen
0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support
0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1,
wl-clipboard-rs 0.9.5
unicode-bidi 0.3.18, unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation
1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2
1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
weezl 0.2.1, wit-bindgen 0.57.1, wl-clipboard-rs 0.9.5
================================================================================
Apache License
@@ -1314,6 +1365,23 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
alloc-no-stdlib 2.0.4, brotli-decompressor 5.0.3
================================================================================
Copyright (c) 2016 Dropbox, Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
allocator-api2 0.2.21, async-trait 0.1.92, dyn-clone 1.0.20, enumn 0.1.14, glifo 0.3.0, itoa 1.0.18,
libc 0.2.190, litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.0.107, quote 1.0.47,
@@ -1933,8 +2001,8 @@ as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, atspi-proxies 0.
0.1.17, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1, multiversion_no_op 1.0.0,
nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0, rustls-platform-verifier
0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions 1.1.0, sys-locale 0.3.2,
ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zeroize 1.9.1,
zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
tinyvec 1.13.3, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2,
zeroize 1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
================================================================================
Apache License
@@ -2890,7 +2958,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
block-buffer 0.12.1
block-buffer 0.12.1, block-padding 0.4.2
================================================================================
Copyright (c) 2018-2025 The RustCrypto Project Developers
@@ -3019,6 +3087,20 @@ limitations under the License.
bytemuck 1.25.2, bytemuck_derive 1.12.1
================================================================================
MIT License
Copyright (c) 2019 Daniel "Lokathor" Gee.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
bytemuck 1.25.2, bytemuck_derive 1.12.1, tinyvec 1.13.3
================================================================================
Copyright (c) 2019 Daniel "Lokathor" Gee.
This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.
@@ -3031,20 +3113,6 @@ Permission is granted to anyone to use this software for any purpose, including
3. This notice may not be removed or altered from any source distribution.
================================================================================
bytemuck 1.25.2, bytemuck_derive 1.12.1
================================================================================
MIT License
Copyright (c) 2019 Daniel "Lokathor" Gee.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
byteorder 1.5.0, byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1
================================================================================
@@ -3187,7 +3255,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
cbc 0.1.2
cbc 0.1.2, cbc 0.2.1
================================================================================
Copyright (c) 2018-2022 RustCrypto Developers
@@ -3290,6 +3358,36 @@ http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
option. All files in the project carrying such notice may not be
copied, modified, or distributed except according to those terms.
================================================================================
chacha20 0.10.2, zeroize_derive 1.5.0
================================================================================
Copyright (c) 2019-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
chardetng 0.1.17
================================================================================
@@ -3364,6 +3462,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cipher 0.5.2
================================================================================
Copyright (c) 2016-2025 RustCrypto Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
color_quant 1.1.0
================================================================================
@@ -3476,6 +3604,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cpubits 0.1.1
================================================================================
Copyright (c) 2023-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cpufeatures 0.2.17
================================================================================
@@ -3534,8 +3692,8 @@ SOFTWARE.
================================================================================
crc32fast 1.5.2, dpi 0.1.2, foreign-types 0.5.0, foreign-types-macros 0.2.4, foreign-types-shared
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_edit 0.25.15+spec-1.1.0,
unicode-general-category 1.1.0, winit 0.30.13
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, stringprep 0.1.5, toml_edit
0.25.15+spec-1.1.0, unicode-general-category 1.1.0, winit 0.30.13
================================================================================
Apache License
@@ -5235,6 +5393,36 @@ and labelled as such in comments in the individual source files. All
have been licensed under extremely permissive terms.
------------------------------------------------------------------------------
================================================================================
ecb 0.2.1
================================================================================
Copyright (c) 2026 RustCrypto Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
egui-wgpu 0.37.0
================================================================================
@@ -7769,6 +7957,37 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
inout 0.2.6
================================================================================
Copyright (c) 2022-2025 The RustCrypto Project Developers
Copyright (c) 2022 Artyom Pavlov
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
jni-sys 0.3.1, jni-sys 0.4.1
================================================================================
@@ -8707,6 +8926,65 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
lopdf 0.45.0
================================================================================
MIT License
Copyright (c) 2016 Junfeng Liu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
md-5 0.11.0, sha1 0.11.0, sha2 0.11.1
================================================================================
Copyright (c) 2016-2026 The RustCrypto Project Developers
Copyright (c) 2016 Artyom Pavlov
Copyright (c) 2009-2013 Mozilla Foundation
Copyright (c) 2006-2009 Graydon Hoare
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
memmap2 0.9.11
================================================================================
@@ -10079,7 +10357,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
rand 0.8.8
rand 0.10.3, rand 0.8.8
================================================================================
Apache License
@@ -10260,7 +10538,7 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
END OF TERMS AND CONDITIONS
================================================================================
rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
rand 0.10.3, rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
================================================================================
Copyright 2018 Developers of the Rand project
@@ -10291,7 +10569,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
rand 0.10.3, rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
================================================================================
Copyrights in the Rand project are retained by their contributors. No
@@ -10569,6 +10847,213 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
rangemap 1.8.0
================================================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2019-2022 Jeff Parsons, and [contributors](https://github.com/jeffparsons/rangemap/contributors)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
rangemap 1.8.0
================================================================================
Copyright 2019 Jeffrey Parsons
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
raw-window-handle 0.6.2
================================================================================
@@ -12276,39 +12761,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
sha1 0.11.0, sha2 0.11.1
================================================================================
Copyright (c) 2016-2026 The RustCrypto Project Developers
Copyright (c) 2016 Artyom Pavlov
Copyright (c) 2009-2013 Mozilla Foundation
Copyright (c) 2006-2009 Graydon Hoare
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
signal-hook-registry 1.4.8
================================================================================
@@ -12814,6 +13266,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
stringprep 0.1.5
================================================================================
Copyright (c) 2017 The rust-stringprep Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
subtle 2.6.1
================================================================================
@@ -12977,6 +13453,16 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
tinyvec 1.13.3
================================================================================
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
tokio 1.53.2
================================================================================
@@ -13881,7 +14367,8 @@ carrying such notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
unicode-bidi 0.3.18, unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation
1.13.3, unicode-width 0.2.2
================================================================================
Copyright (c) 2015 The Rust Project Developers
@@ -13911,7 +14398,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
unicode-ident 1.0.26
unicode-ident 1.0.26, unicode-normalization 0.1.26
================================================================================
UNICODE LICENSE V3
@@ -13954,6 +14441,19 @@ not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
================================================================================
unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width
0.2.2
================================================================================
Licensed under the Apache License, Version 2.0
<LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0> or the MIT
license <LICENSE-MIT or http://opensource.org/licenses/MIT>,
at your option. All files in the project carrying such
notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
unicode-properties 0.1.5
================================================================================
@@ -13998,18 +14498,6 @@ not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
================================================================================
unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Licensed under the Apache License, Version 2.0
<LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0> or the MIT
license <LICENSE-MIT or http://opensource.org/licenses/MIT>,
at your option. All files in the project carrying such
notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
untrusted 0.9.0
================================================================================
@@ -14726,7 +15214,7 @@ of Data, including for example machine learning models and models'
insights.
================================================================================
weezl 0.1.12
weezl 0.1.12, weezl 0.2.1
================================================================================
The MIT License (MIT)
@@ -15786,36 +16274,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
zeroize_derive 1.5.0
================================================================================
Copyright (c) 2019-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
zip 9.1.3
================================================================================
@@ -16135,6 +16593,7 @@ accesskit_macos 0.27.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesski
accesskit_unix 0.24.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_windows 0.35.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_winit 0.34.2: Apache-2.0 (https://github.com/AccessKit/accesskit)
alloc-stdlib 0.2.4: BSD-3-Clause (https://github.com/dropbox/rust-alloc-no-stdlib)
block2 0.5.1: MIT (https://github.com/madsmtm/objc2)
block2 0.6.2: MIT (https://github.com/madsmtm/objc2)
cadmpeg-codec-freecad 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
+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
+1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "إيقاف السجل",
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.ui.palette": "البحث في الأوامر",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "مستعرض التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
+1
View File
@@ -1319,6 +1319,7 @@
"cmd.timeline_stop": "Verlauf anhalten",
"cmd.timeline_suppress": "Merkmal unterdrücken",
"cmd.ui.assistant": "Assistenten ein-/ausblenden",
"cmd.ui.palette": "Befehle suchen",
"cmd.view.navigator": "Eigener Ansichtsnavigator",
"cmd.view.render_browser": "Render-Browser",
"cmd.view.render_dock": "Render-Panel verschieben",
+1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "Stop History",
"cmd.timeline_suppress": "Suppress Feature",
"cmd.ui.assistant": "Toggle Assistant",
"cmd.ui.palette": "Search Commands",
"cmd.view.navigator": "Custom View Navigator",
"cmd.view.render_browser": "Render Browser",
"cmd.view.render_dock": "Move Render Dock",
+1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "עצירת ציר הזמן",
"cmd.timeline_suppress": "השבתת פיצ'ר",
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
"cmd.ui.palette": "חיפוש פקודות",
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
"cmd.view.render_browser": "דפדפן רינדור",
"cmd.view.render_dock": "הזז את עגינת הרינדור",
+1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "履歴を停止",
"cmd.timeline_suppress": "フィーチャーを抑制",
"cmd.ui.assistant": "アシスタントの表示切り替え",
"cmd.ui.palette": "コマンドを検索",
"cmd.view.navigator": "カスタム ビューナビゲーター",
"cmd.view.render_browser": "Renderブラウザー",
"cmd.view.render_dock": "レンダードックを移動",
+1
View File
@@ -1252,6 +1252,7 @@
"cmd.timeline_stop": "기록 재생 중지",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.ui.assistant": "도우미 표시 전환",
"cmd.ui.palette": "명령 검색",
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
"cmd.view.render_browser": "렌더 브라우저",
"cmd.view.render_dock": "렌더 도크 이동",
+1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "Dừng lịch sử",
"cmd.timeline_suppress": "Tạm nén tính năng",
"cmd.ui.assistant": "Bật/tắt trợ lý",
"cmd.ui.palette": "Tìm kiếm lệnh",
"cmd.view.navigator": "Khối điều hướng góc nhìn tùy chỉnh",
"cmd.view.render_browser": "Trình duyệt kết xuất",
"cmd.view.render_dock": "Di chuyển neo kết xuất",
+1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "停止历史播放",
"cmd.timeline_suppress": "抑制特征",
"cmd.ui.assistant": "切换助手显示",
"cmd.ui.palette": "搜索命令",
"cmd.view.navigator": "自定义视图导航器",
"cmd.view.render_browser": "渲染浏览器",
"cmd.view.render_dock": "移动渲染停靠面板",
+64
View File
@@ -0,0 +1,64 @@
//! CSV text fields, as every SolidCraft export writes them and the parameter import reads them.
//!
//! A spreadsheet reads a field that starts with `=`, `+`, `-` or `@` as a formula, so a part
//! numbered `=1+1` would show `2`. A field like that is written with a `'` first, which
//! spreadsheets show as text (OWASP's CSV Injection page). [`unguard`] takes the `'` off again
//! when SolidCraft reads its own file back.
use std::borrow::Cow;
/// The first characters that make a spreadsheet read a field as a formula, from OWASP's list.
/// The full-width forms act as formulas in some locales, such as Japanese, so they are here too.
const FORMULA_STARTS: [char; 11] = ['=', '+', '-', '@', '\t', '\r', '\n', '=', '+', '-', '@'];
/// What makes a field need quotes: a separator (`;` as well as `,`, since a spreadsheet set to
/// `;` splits there and the new cell could start a formula), a quote, a tab or a line break.
const NEEDS_QUOTES: [char; 6] = [',', ';', '"', '\t', '\r', '\n'];
/// A text field, quoted only when it needs quotes.
pub fn field(text: &str) -> String {
let text = guard(text);
if text.contains(NEEDS_QUOTES) { quote(&text) } else { text.into_owned() }
}
/// A text field, always in quotes.
pub fn quoted(text: &str) -> String {
quote(&guard(text))
}
/// A field as SolidCraft wrote it, with the `'` that [`field`] or [`quoted`] put first taken off.
/// A field without one is returned as it is.
pub fn unguard(text: &str) -> &str {
match text.strip_prefix('\'') {
Some(rest) if starts_a_formula(rest) => rest,
_ => text,
}
}
/// `text` with a `'` first when a spreadsheet would read it as a formula. A field that already
/// starts with `'` and then a formula gets one more, so [`unguard`] gives back exactly what was
/// written. A whole plain number (`-5`, `+1e-9`) is read as that number, not as a formula, so it
/// is left as it is.
fn guard(text: &str) -> Cow<'_, str> {
if starts_a_formula(text) && !plain_number(text) { Cow::Owned(format!("'{text}")) } else { Cow::Borrowed(text) }
}
/// Whether `text`, after any `'` and spaces it starts with, starts as a formula does. Spaces count
/// because a spreadsheet may take them off as it reads (LibreOffice's "Trim spaces").
fn starts_a_formula(text: &str) -> bool {
text.trim_start_matches(['\'', ' ']).starts_with(FORMULA_STARTS)
}
/// A finite number and nothing else, spaces included. Rust's `f64` parsing also takes `inf`,
/// `infinity` and `NaN` in any case, and none of them is finite.
fn plain_number(text: &str) -> bool {
text.parse::<f64>().is_ok_and(f64::is_finite)
}
fn quote(text: &str) -> String {
format!("\"{}\"", text.replace('"', "\"\""))
}
#[cfg(test)]
#[path = "csv_tests.rs"]
mod tests;
+89
View File
@@ -0,0 +1,89 @@
use super::{FORMULA_STARTS, NEEDS_QUOTES, field, quoted, unguard};
#[test]
fn a_field_a_spreadsheet_would_run_gets_an_apostrophe() {
for (text, written) in [
("=1+1", "'=1+1"),
("+2 for the fit", "'+2 for the fit"),
("-5 mm shim", "'-5 mm shim"),
("@SUM(1,1)", "\"'@SUM(1,1)\""),
("=1+1", "'=1+1"),
("+2", "'+2"),
("-5", "'-5"),
("@x", "'@x"),
("\tx", "\"'\tx\""),
("\rx", "\"'\rx\""),
("\nx", "\"'\nx\""),
// Already an apostrophe, then a formula: one more, so the import gives back the original.
("'=x", "''=x"),
("''-x", "'''-x"),
// Spaces before a formula count: a spreadsheet may take them off.
(" =x", "' =x"),
(" -5", "' -5"),
("' @x", "'' @x"),
] {
assert_eq!(field(text), written, "{text:?}");
assert_eq!(unguard(written.trim_matches('"')), text, "{written:?}");
}
}
#[test]
fn a_whole_plain_number_stays_a_number() {
for number in ["-5", "+5", "-0", "0", "1e-9", "-1.5E+3", ".5", "-.5", "5.", "+1e5", "-12.000000000"] {
assert_eq!(field(number), number, "{number:?}");
}
// Not plain numbers: Rust's parse takes the words, spaces aren't a number's, and one too large isn't finite.
for text in ["-inf", "+inf", "-infinity", "-NaN", "+nan", "-", "+", "--5", "-5 mm", "-5 ", "-1e400", "-0x10", "-5,5"] {
assert!(field(text).trim_start_matches('"').starts_with('\''), "{text:?}: {}", field(text));
}
}
#[test]
fn other_fields_are_left_as_they_are() {
for text in ["", "Plate", "PL-100", "a=b", "x+1", "'quoted' word", "'", "''", "5 mm", " 5", "é=", "μ-5"] {
assert_eq!(field(text), text, "{text:?}");
assert_eq!(unguard(text), text, "{text:?}");
}
}
#[test]
fn a_field_with_a_separator_a_quote_or_a_break_is_quoted() {
assert_eq!(field("Plate, 3 mm"), "\"Plate, 3 mm\"");
assert_eq!(field("one; two"), "\"one; two\"");
assert_eq!(field("Pin \"short\""), "\"Pin \"\"short\"\"\"");
assert_eq!(field("a\tb"), "\"a\tb\"");
assert_eq!(field("line one\r\nline two"), "\"line one\r\nline two\"");
assert_eq!(quoted("Plate"), "\"Plate\"");
assert_eq!(quoted("=A1"), "\"'=A1\"");
assert_eq!(quoted("-5"), "\"-5\"");
}
/// Random fields from an alphabet heavy in what matters: every formula start, apostrophes, quotes,
/// separators, spaces, digits and the letters of `inf` and `NaN`. Each one reads back exactly, never starts
/// a formula once written unless it is a whole number, and is quoted whenever it holds a separator.
#[test]
fn fuzz_every_field_reads_back_and_never_starts_a_formula() {
let mut alphabet: Vec<char> = "'''\"\";,.eE0123456789 infaNxé".chars().collect();
alphabet.extend(FORMULA_STARTS);
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for _ in 0..50_000 {
let n = (rnd() % 12) as usize;
let text: String = (0..n).filter_map(|_| alphabet.get((rnd() % alphabet.len() as u64) as usize)).collect();
let written = field(&text);
let inner = match written.strip_prefix('"').and_then(|w| w.strip_suffix('"')) {
Some(inner) => inner.replace("\"\"", "\""),
None => written.clone(),
};
assert_eq!(unguard(&inner), text, "{text:?} written as {written:?}");
let bare = inner.trim_start_matches(' ');
assert!(!bare.starts_with(FORMULA_STARTS) || text.parse::<f64>().is_ok_and(f64::is_finite), "{text:?} written as {written:?}");
assert!(!text.contains(NEEDS_QUOTES) || written.starts_with('"'), "{text:?} written as {written:?}");
assert_eq!(quoted(&text), format!("\"{}\"", inner.replace('"', "\"\"")), "{text:?}");
}
}
+225 -34
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;
@@ -4754,9 +4866,13 @@ fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at:
// Pick an actual cylindrical wall, not a depth derived from its radius: a large pipe bore can
// be shallow or pass through a thin plate. Sampling its finite face also skips a wider entry
// pocket and keeps metadata attached to the narrow cylindrical bore.
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
//
// A hole drilled across a pocket or a slot leaves its wall in pieces along the axis (#13505):
// every piece the hole reaches (`reach`, its depth from `entry`) is returned, nearest the entry
// first, each as (its start and end from the entry along `inward`, a point on it).
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64, reach: f64) -> Vec<(f64, f64, Vec3)> {
let tol = (radius * 1e-6).max(1e-6);
let mut best: Option<(f64, Vec3)> = None;
let mut walls: Vec<(f64, f64, Vec3)> = Vec::new();
for body in &st.bodies {
// A wall found lies on the line through `entry` along `inward`, give or take its radius
// (and the slant its axis may have off `inward` over the body's distance), and its
@@ -4783,12 +4899,15 @@ fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64)
let b = (c.axis_point + c.axis * c.end - entry).dot(inward);
let lo = a.min(b).max(0.0);
let hi = a.max(b);
if hi > lo + 1e-9 && best.as_ref().is_none_or(|(old, _)| lo < *old) {
best = Some((lo, entry + inward * ((lo + hi) * 0.5) + inward.any_perp() * radius));
// One piece of wall made of several faces (a cylinder split at its seams) is one.
let mid = (lo + hi) * 0.5;
if hi > lo + 1e-9 && lo < reach - 1e-6 && !walls.iter().any(|(l, h, _)| (*l..=*h).contains(&mid)) {
walls.push((lo, hi, entry + inward * mid + inward.any_perp() * radius));
}
}
}
best.map(|(_, wall)| wall)
walls.sort_by(|a, b| a.0.total_cmp(&b.0));
walls
}
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
@@ -6959,6 +7078,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).
@@ -7516,7 +7659,7 @@ fn eval_feature(
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
}
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, depth, to, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
@@ -7531,6 +7674,19 @@ fn eval_feature(
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
}
let (spots, dir) = hole_spots(vals, st, f)?;
// How deep each hole goes (through all: as far as the wall goes), read before the cut:
// the pieces of a wall the thread runs along lie within it.
let reaches: Vec<f64> = if thread.is_some() {
spots
.iter()
.map(|at| match hole_depth_to(st, at, dir, &to.map(Vec3::from), depth)? {
Some(e) => val(vals, &e, Kind::Length),
None => Ok(f64::INFINITY),
})
.collect::<Result<_>>()?
} else {
Vec::new()
};
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
@@ -7566,42 +7722,77 @@ fn eval_feature(
)
});
let ease = thread::runout(vals, ease_out)?;
for p in spots {
let wall = if pipe {
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
for (p, reach) in spots.into_iter().zip(reaches) {
let walls = parallel_tapped_wall(st, p, dir, r, reach);
if pipe && walls.is_empty() {
return Err(DocError::Invalid("no cylindrical pipe-thread wall remains".into()));
}
// A pipe thread is one taper from the entry: its first wall only.
let walls: Vec<(f64, f64, Vec3)> = match walls.first() {
None => vec![(0.0, f64::INFINITY, p + dir.any_perp() * r + dir * (r * 0.5))],
Some(first) if pipe => vec![*first],
Some(_) => walls,
};
// The thread depth runs from where the first wall starts, through every piece
// of the wall it reaches (#13505).
let first = walls.first().map_or(0.0, |w| w.0);
let stop = thread_depth.map(|d| first + d);
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
if *modeled {
// Cut from the entry, as deep as `thread_depth` or along the whole wall:
// at a blind hole's bottom the groove stops clear of it, or fades out
// before it with a runout.
let extent = thread_depth.map_or(thread::Extent::Full, thread::Extent::Length);
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(p), ease_out: ease, ..ask };
let on = thread_on(st, f.id, wall, &ask)?;
thread::cut(st, on, (crate::thread_forms::ThreadTip::None, None, None))?;
// before it with a runout. Every piece is found before any is cut.
let mut on = Vec::new();
for (lo, hi, wall) in &walls {
let extent = match stop {
None => thread::Extent::Full,
Some(s) if s >= *hi => thread::Extent::Full,
Some(s) if s > *lo => thread::Extent::Length(s - lo),
Some(_) => break,
};
// A piece past the first starts at its end nearest the entry.
let from = if on.is_empty() { p } else { p + dir * *lo };
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(from), ease_out: ease, ..ask };
on.push(thread_on(st, f.id, *wall, &ask)?);
}
for o in on {
thread::cut(st, o, (crate::thread_forms::ThreadTip::None, None, None))?;
}
continue;
}
let mut t = thread_on(st, f.id, wall, &ask)?.info;
let mut threads = Vec::new();
for (lo, _, wall) in &walls {
if stop.is_some_and(|s| s <= *lo) {
break;
}
threads.push(thread_on(st, f.id, *wall, &ask)?.info);
}
if let Some(depth) = thread_depth {
let available = t.end - t.start;
if depth > available {
let useful =
|t: &ThreadInfo| (t.axis_point + t.axis * t.start - p).dot(dir).max((t.axis_point + t.axis * t.end - p).dot(dir));
let available = threads.last().map_or(0.0, |t| useful(t) - first);
if depth > available + 1e-9 {
return Err(DocError::Invalid(format!(
"thread depth {} mm exceeds the cylindrical wall's useful length of {} mm; reduce the thread depth or use full thread depth",
show_mm(depth),
show_mm(available)
)));
}
// Start at the entry in the drilling direction, independently of the
// wall probe's position and the cylinder surface's axis orientation.
if dir.dot(t.axis) > 0.0 {
t.end = t.start + depth;
} else {
t.start = t.end - depth;
// Stop `depth` past the first wall's start in the drilling direction,
// independently of the wall probe's position and the cylinder surface's
// axis orientation.
if let (Some(t), Some(s)) = (threads.last_mut(), stop) {
let past = useful(t) - s;
if past > 0.0 {
if dir.dot(t.axis) > 0.0 {
t.end -= past;
} else {
t.start += past;
}
}
}
}
st.threads.push(t);
st.threads.extend(threads);
}
}
Ok(())
+1
View File
@@ -22,6 +22,7 @@ mod component_removal;
pub use component_removal::RemovedTree;
pub mod config;
pub mod construct;
pub mod csv;
pub mod display_quality;
pub mod display_units;
mod document;
+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),
+11 -2
View File
@@ -50,8 +50,11 @@ pub struct EdgeHit {
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names exactly (see [`exactly`]). `Err` explains when there is none: no
/// such edge, the named edge split into several, or a name saved before format 14.
/// between the faces it names exactly (see [`exactly`]). A numbered name (`~k`) was one of
/// several edges between its faces, told apart only by where they lay, so the one edge left
/// between them is it only where the reference's point still lies on it. `Err` explains when
/// there is none: no such edge, the named edge split into several, one of several edges whose
/// faces now bound one edge elsewhere, or a name saved before format 14.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if legacy_name(name).is_some() {
return Err(format!("edge `{name}` was named by a build before format 14"));
@@ -81,7 +84,13 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
keep_best(&mut best, level, i);
}
let (_, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
// When the faces of several numbered edges bound one edge now, every one of their names
// finds the faces of that edge: taking it for each would put them all on one edge (#13332).
let numbered = wanted.len() != name.len();
match cands.as_slice() {
[i] if numbered && !edge_near(b, &m, *i, near) => {
Err(format!("edge `{name}` was one of several between its faces, and the one left is elsewhere"))
}
[i] => exact(*i),
_ => Err(format!("edge `{name}` is no longer one edge")),
}
+6 -4
View File
@@ -483,9 +483,10 @@ impl Document {
}
/// User parameters as CSV (`name,unit,expression,value,comment`). The `value` column is the
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`.
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`. A text
/// field a spreadsheet would run as a formula is written as text ([`crate::csv`]).
pub fn params_csv(&self) -> String {
let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
let q = crate::csv::field;
let mut out = String::from("name,unit,expression,value,comment,favorite\n");
for r in self.param_rows().into_iter().filter(|r| r.source == "user") {
out += &format!(
@@ -1137,13 +1138,14 @@ fn split_csv(text: &str, separator: char) -> impl Iterator<Item = &str> {
fn csv_fields(record: &str) -> Result<Vec<String>> {
split_csv(record, ',')
.map(|raw| {
// `unguard` takes off the `'` an export put before a value a spreadsheet would run.
let field = raw.trim();
if !field.contains('"') {
return Ok(field.to_string());
return Ok(crate::csv::unguard(field).to_string());
}
let inner = field.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
match inner {
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(s.replace("\"\"", "\"")),
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(crate::csv::unguard(&s.replace("\"\"", "\"")).to_string()),
_ => Err(DocError::Invalid("invalid quoting in parameter CSV".into())),
}
})
+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;
}
+3 -6
View File
@@ -421,13 +421,10 @@ fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits
out
}
/// A CSV field, always quoted.
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\""))
}
/// The bill of materials as CSV: fixed columns, every field quoted.
/// The bill of materials as CSV: fixed columns, every field quoted, and a field a spreadsheet
/// would run as a formula written as text.
fn csv(rows: &[Value]) -> String {
let quote = solidcraft_doc::csv::quoted;
let mut out = ["item", "part_number", "description", "revision", "count", "definition"].map(quote).join(",");
out.push('\n');
let lines = rows.iter().flat_map(|r| std::iter::once(r).chain(r["children"].as_array().into_iter().flatten()));
+14
View File
@@ -201,6 +201,20 @@ fn csv_quotes_every_field_and_json_writes_the_rows() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A field that a spreadsheet would run as a formula gets a `'` first, so it opens as text
/// (#12728). A count stays a number.
#[test]
fn a_csv_field_a_spreadsheet_would_run_opens_as_text() {
let dir = scratch("formula");
let mut s = assembly();
run(&mut s, "component.metadata.set", json!({"component": "Plate", "part_number": "=1+1", "description": "-5 mm shim", "revision": "@A"}));
let csv = dir.join("bom.csv");
run(&mut s, "assembly.bom", json!({"path": csv}));
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.contains("\n\"1\",\"'=1+1\",\"'-5 mm shim\",\"'@A\",\"3\",\"Plate\"\n"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn standard_parts_carry_a_part_number_that_follows_a_resize() {
let mut s = Session::default();
+16
View File
@@ -30,6 +30,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ASSISTANT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::AssistantResult>)
.params("Show or hide Assistant in the app. Headless calls are refused without changing the design"),
CommandSpec::new("ui.palette", "Search Commands", crate::checked!("ui.palette", palette))
.icon("zoom")
.key("S")
.noundo()
.doc_only()
.contract(&PALETTE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::PaletteResult>)
.params("Open command search in the app. Headless calls are refused without changing the design"),
CommandSpec::new("edit.undo", "Undo", crate::checked!("edit.undo", undo))
.icon("undo")
.key("Ctrl+Z")
@@ -257,6 +265,14 @@ fn assistant(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.assistant shows or hides Assistant in the app; no assistant panel exists in a headless session".into()))
}
const PALETTE_CONTRACT: crate::schema::TypedContract<crate::schema::edit::PaletteParams> = crate::schema::TypedContract::new();
/// Command search (#12190) is the app's: it opens in `SolveApp::start`, and its key can be moved
/// in Preferences › Shortcuts like any other command's.
fn palette(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.palette opens command search in the app; there is no search box in a headless session".into()))
}
const REPEAT_CONTRACT: crate::schema::TypedContract<crate::schema::edit::RepeatParams> = crate::schema::TypedContract::new();
/// Repeat Last Command (#7077) starts a tool, which only the app has: it starts the last one in
+6
View File
@@ -1687,6 +1687,12 @@ fn assistant_toggle_is_refused_headlessly_without_changing_the_document() {
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.assistant").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("Ctrl+Shift+A"), "", false));
// Command search likewise (#12190): S is its default key, not a reserved one.
let error = s.execute("ui.palette", &json!({})).unwrap_err().to_string();
assert!(error.contains("headless session"), "{error}");
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.palette").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("S"), "", false));
}
#[test]
+15 -5
View File
@@ -15,14 +15,16 @@ use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, s
use crate::schema::features::SweepParams;
use crate::schema::features::{
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
SphereParams, TorusParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams,
ScaleParams, SphereParams, TorusParams,
};
use crate::schema::split::{SplitBodyParams, SplitFaceParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
const EXTRUDE_CONTRACT: TypedContract<ExtrudeParams> = TypedContract::new();
const REVOLVE_CONTRACT: TypedContract<RevolveParams> = TypedContract::new();
const CYLINDER_CONTRACT: TypedContract<CylinderParams> = TypedContract::new();
const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
@@ -36,6 +38,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))
@@ -45,9 +49,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXTRUDE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (how a symmetric distance is read; default total_distance, split evenly around the start plane; legacy_per_side: the distance on each side, as older symmetric extrudes keep); distance2?: expr (Two Sides: a second distance on the side direction leaves free, the first side may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it; a negative distance only turns its own side over); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
CommandSpec::new("solid.revolve", "Revolve", crate::checked!("solid.revolve", revolve))
.at("SOLID", "CREATE")
.icon("revolve")
.contract(&REVOLVE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
.at("SOLID", "CREATE")
@@ -173,15 +179,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")
+78 -28
View File
@@ -13,6 +13,7 @@ use super::features::{
add_feature, axis_dir_param, check_expr, feature_sketch, length_operands, plane_param, point_param, profiles, typed_length_unit,
};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::schema::align::AlignParams;
use crate::schema::features::FeatureResult;
use crate::schema::features_more::{BoundaryFillParams, RemoveParams, RibParams};
use crate::schema::{TypedContract, output_schema_for};
@@ -20,6 +21,7 @@ use crate::{Result, Session};
/// Rib and Web take the same keys.
const RIB_CONTRACT: TypedContract<RibParams> = TypedContract::new();
const ALIGN_CONTRACT: TypedContract<AlignParams> = TypedContract::new();
const REMOVE_CONTRACT: TypedContract<RemoveParams> = TypedContract::new();
const BOUNDARY_FILL_CONTRACT: TypedContract<BoundaryFillParams> = TypedContract::new();
@@ -58,9 +60,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("replace_face")
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", align)
CommandSpec::new("solid.align", "Align", crate::checked!("solid.align", align))
.at("SOLID", "MODIFY")
.icon("align")
.contract(&ALIGN_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); from_frame / to_frame: a frame found again whenever the design changes, so the bodies follow it, in place of that side's point, face or plane: a joint origin as joint.create takes one ({face|circle|edge|point: [x,y,z]}, a face's name, a named joint origin) or {sketch, point|curve} (a sketch point, or a circle's, arc's or ellipse's centre, z along the sketch's normal), on the bodies' own component; faces, planes and frames also turn to meet; flip?: bool; axes?: [x, y, z] (point-to-point only: which world axes the offset moves along; default all three, at least one true); angle?: expr (a turn about the target's direction after the move)"),
CommandSpec::new("solid.remove", "Remove", crate::checked!("solid.remove", remove))
.at("SOLID", "MODIFY")
@@ -421,21 +425,58 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::ReplaceFace { faces: faces.into_iter().map(FacePick).collect(), target, body: body.map(BodyName) })
}
/// What `solid.align` says when `axes` is not three booleans, or when none is true.
pub(crate) const AXES_SHAPE: &str = "`axes` must be [x, y, z] booleans";
pub(crate) const AXES_NONE: &str = "`axes` must enable at least one of X, Y and Z";
/// `axes` as `solid.align` reads it: three booleans, one per world axis, at least one of them
/// true; the message is the handler's.
pub(crate) fn axes_mask(v: &Value) -> std::result::Result<[bool; 3], &'static str> {
let a = v.as_array().filter(|a| a.len() == 3).ok_or(AXES_SHAPE)?;
let mut mask = [false; 3];
for (slot, v) in mask.iter_mut().zip(a) {
*slot = v.as_bool().ok_or(AXES_SHAPE)?;
}
if !mask.iter().any(|on| *on) {
return Err(AXES_NONE);
}
Ok(mask)
}
/// `axes`: three booleans, one per world axis, for a point-to-point Align.
/// `None` when the call does not restrict the move.
fn axes_param(p: &Value, cmd: &str) -> Result<Option<[bool; 3]>> {
let Some(v) = p.get("axes").filter(|v| !v.is_null()) else { return Ok(None) };
let a = v.as_array().filter(|a| a.len() == 3).ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?;
let mut mask = [false; 3];
for (slot, v) in mask.iter_mut().zip(a) {
*slot = v.as_bool().ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?;
}
if !mask.iter().any(|on| *on) {
return Err(bad(cmd, "`axes` must enable at least one of X, Y and Z"));
}
let mask = axes_mask(v).map_err(|why| bad(cmd, why))?;
Ok((mask != [true; 3]).then_some(mask))
}
/// An Align side given as a point (`from` or `to`), as the handler reads it.
pub(crate) enum SidePoint {
/// The text `origin` (any case): the world origin.
Origin,
/// `{snap?, at}`: the nearest `snap` (vertex unless the object says another) to `at`.
Snap { at: Vec3, kind: String },
/// `[x, y, z]`.
Point(Vec3),
}
/// The side `pk` (`from` or `to`) as a point, with the handler's message when it is none of the
/// forms; `fk` is the face key beside it (`from_face`).
pub(crate) fn side_point(v: Option<&Value>, pk: &str, fk: &str) -> std::result::Result<SidePoint, String> {
// The origin point.
if v.and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok(SidePoint::Origin);
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = v.filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| format!("`{pk}.at` must be [x, y, z]"))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex").to_string();
return Ok(SidePoint::Snap { at, kind });
}
v.and_then(vec3).map(SidePoint::Point).ok_or_else(|| format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)"))
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.align";
let bodies = string_list(cmd, p, "bodies")?;
@@ -492,20 +533,13 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let n = super::face::face_normal_world(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// The origin point.
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
match side_point(p.get(pk), pk, fk).map_err(|why| bad(cmd, why))? {
SidePoint::Origin => Ok((Vec3::ZERO, None)),
SidePoint::Snap { at, kind } => {
snap_point(&*s.world_state()?, &kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")))
}
SidePoint::Point(v) => Ok((v, None)),
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(&*s.world_state()?, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
};
let pick = |s: &Session, pk: &str, fk: &str, held: bool| -> Result<(Vec3, Option<Vec3>)> {
let (at, n) = world_pick(s, pk, fk, held)?;
@@ -568,7 +602,24 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The longest sketch point or curve id an Align frame takes.
const MAX_SKETCH_ID: usize = 256;
pub(crate) const MAX_SKETCH_ID: usize = 256;
/// The point or curve of an Align `{sketch, point|curve}` frame: an id of 1 to
/// [`MAX_SKETCH_ID`] characters. `None` when the frame gives both, neither or an id that is not
/// one.
pub(crate) enum SketchFrameId {
Point(String),
Curve(String),
}
pub(crate) fn sketch_frame_id(v: &Value) -> Option<SketchFrameId> {
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
match (id("point"), id("curve")) {
(Some(point), None) => Some(SketchFrameId::Point(point)),
(None, Some(curve)) => Some(SketchFrameId::Curve(curve)),
_ => None,
}
}
/// An Align side's frame (#6257), `<key>_frame`: a joint origin as `joint.create` takes one, or a
/// sketch point or the centre of a sketch circle, arc or ellipse (`{sketch, point|curve}`), on the
@@ -585,11 +636,10 @@ fn align_frame(s: &Session, p: &Value, key: &str, bodies: &[String]) -> Result<O
let (frame, on) = match v.get("sketch") {
Some(sk) => {
let sketch = super::features::sketch_id(s, Some(sk), cmd, &fk)?;
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
let frame = match (id("point"), id("curve")) {
(Some(point), None) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
(None, Some(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
_ => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
let frame = match sketch_frame_id(v) {
Some(SketchFrameId::Point(point)) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
Some(SketchFrameId::Curve(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
None => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
};
(frame, s.doc.feature(sketch).map_or(0, |f| f.component))
}
+48 -19
View File
@@ -23,10 +23,16 @@ use solidcraft_io::Format;
use super::CommandSpec;
use super::print_face;
use crate::params::{bad, bool_, num, str_, string_list};
use crate::schema::file::ExportResult;
use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
#[cfg(not(target_arch = "wasm32"))]
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const INSERT_MESH_CONTRACT: TypedContract<InsertMeshParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open)
@@ -38,9 +44,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?, group?: the folder); folder?: bool (default false; true: an import that brings more than one item into the history (its Import feature and the rigid joints holding its parts) goes into a new history folder named `Group #N`); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
CommandSpec::new("file.insert_mesh", "Insert Mesh", crate::checked!("file.insert_mesh", insert_mesh))
.at("SOLID", "INSERT")
.icon("import")
.contract(&INSERT_MESH_CONTRACT)
.output(output_schema_for::<MeshInserted>)
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
.icon("save")
@@ -63,9 +71,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
@@ -100,10 +110,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXPORT_URDF_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::UrdfExportResult>)
.params("path; mesh_prefix?; effort? (default 1000); velocity? (default 10) → {path, format, files, links, joints, warnings}: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh)
CommandSpec::new("file.save_mesh", "Save As Mesh", crate::checked!("file.save_mesh", save_mesh))
.at("INSERT", "FILES")
.icon("mesh_export")
.noundo()
.contract(&SAVE_MESH_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::save_mesh::SaveMeshResult>)
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); 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)"),
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
.enabled(starts_programs)
@@ -128,6 +140,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();
@@ -135,6 +148,7 @@ const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::Exp
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
const EXPORT_OBJ_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const EXPORT_URDF_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportUrdfParams> = crate::schema::TypedContract::new();
const SAVE_MESH_CONTRACT: crate::schema::TypedContract<crate::schema::save_mesh::SaveMeshParams> = crate::schema::TypedContract::new();
fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))
@@ -307,18 +321,24 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
// After the turn, so it is the stood-up mesh that sits on Z = 0.
let moved = if origin { import::move_to_origin(s, &placed)? } else { None };
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings});
placed.report(&mut out);
if let Some(shift) = moved {
out["moved_to_origin"] = json!([shift.x, shift.y, shift.z]);
}
if let Some((name, _)) = unit {
out["units"] = json!(name);
}
if let Some(turned) = turned {
out["stand_up"] = json!(turned);
}
Ok(out)
let reply = MeshInserted {
path: path.to_string(),
feature: id,
name: fname,
bodies: bodies.clone(),
created_bodies: bodies,
warnings: mf.warnings,
component: placed.component,
component_name: placed.name.clone(),
occurrence: placed.occurrence,
subcomponents: (placed.components > 0).then_some(placed.components),
rigid_joints: (placed.components > 0).then_some(placed.joints.len()),
moved_to_origin: moved.map(|shift| [shift.x, shift.y, shift.z]),
units: unit.map(|(name, _)| name.to_string()),
stand_up: turned,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad("file.insert_mesh", e.to_string()))
}
/// Turn an import's component a quarter turn about X (the file's Y up becomes Z up), contents
@@ -977,7 +997,9 @@ fn export_to(
solidcraft_io::export_with_parts(&s.doc.painted(&*s.world_state()?), bodies, parts, format, &name, &s.doc.metadata, quality)?
};
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
let reply =
ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), files: Vec::new(), param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
@@ -995,9 +1017,16 @@ fn export_laid_to(
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality)?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut out = json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")});
laid.report(&mut out);
Ok(out)
let reply = SaveMeshResult {
path: path.to_string(),
bytes: bytes.len(),
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
delivery: SlicerDelivery::default(),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
+12 -5
View File
@@ -13,6 +13,7 @@ use solidcraft_io::Format;
use super::easyprint::{Exported, export_bodies};
use super::export_laid_to;
use crate::params::bad;
use crate::schema::save_mesh::SlicerDelivery;
use crate::slicers::{self, Slicer, TargetKind};
use crate::{EngineError, Result, Session};
@@ -120,18 +121,24 @@ fn deliver_via(
out: &mut Value,
) {
let Some(obj) = out.as_object_mut() else { return };
// What happened, as the typed value the reply's schema describes; only its one key is set.
let mut say = |delivery: SlicerDelivery| {
if let Ok(Value::Object(keys)) = serde_json::to_value(delivery) {
obj.extend(keys);
}
};
// A target that drops a file given at a cold start is started alone, and sent the file once it is ready.
if wait != Wait::No && target.waits_for_ready().is_some() && target.ready_via(busctl, slicers::PROBE_LIMIT) == Some(false) {
if let Err(e) = target.start(single_instance) {
obj.insert("slicer_error".into(), json!(e));
say(SlicerDelivery { slicer_error: Some(e), ..Default::default() });
return;
}
if wait == Wait::Return {
obj.insert("starting".into(), json!(target.name));
say(SlicerDelivery { starting: Some(target.name.clone()), ..Default::default() });
return;
}
if !slicers::wait_for_ready_via(target, busctl, limit, poll) {
obj.insert("slicer_timeout".into(), json!(target.name));
say(SlicerDelivery { slicer_timeout: Some(target.name.clone()), ..Default::default() });
return;
}
// After a start the file has to reach the instance that is now running, so the flag goes
@@ -140,8 +147,8 @@ fn deliver_via(
}
// The file is saved either way; a program that won't start is reported, not fatal.
match target.open(path, single_instance) {
Ok(()) => obj.insert("slicer".into(), json!(target.name)),
Err(e) => obj.insert("slicer_error".into(), json!(e)),
Ok(()) => say(SlicerDelivery { slicer: Some(target.name.clone()), ..Default::default() }),
Err(e) => say(SlicerDelivery { slicer_error: Some(e), ..Default::default() }),
};
}
+1
View File
@@ -46,6 +46,7 @@ pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub(crate) use features::{rail_fit_form, sweep_orientation};
pub(crate) use features_more::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, side_point, sketch_frame_id};
pub use file::open_steps::OpenSteps;
pub(crate) use inspect::color_param;
pub use join_pieces::{Addition, addition};
+1 -1
View File
@@ -913,7 +913,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
c += &format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
Value::from(r.step),
at.occurrence.replace(',', " "),
solidcraft_doc::csv::field(&at.occurrence),
o[0],
o[1],
o[2],
+19
View File
@@ -687,6 +687,25 @@ fn motion_study_plays_and_exports() {
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
/// A placement's name in a motion CSV is one field, whatever it holds (#12728). A comma is kept in
/// quotes, where it used to become a space; a quote or a line break no longer splits the row; and a
/// name that a spreadsheet would run as a formula opens as text.
#[test]
fn a_motion_csv_keeps_each_placement_name_in_its_field() {
let mut s = rail();
run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
run(&mut s, "component.rename", json!({"component": "B", "name": "=B, \"x\"\ny"}));
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-motion-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("names.csv");
run(&mut s, "motion.export", json!({"study": "Push", "samples": 2, "path": csv.to_string_lossy()}));
let text = std::fs::read_to_string(&csv).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(text.contains(",A:1,") && text.contains(",\"'=B, \"\"x\"\"\ny:1\","), "{text}");
// A header, a row for each placement at each of the two samples, and the line break in each of B's rows.
assert_eq!(text.matches('\n').count(), 1 + 2 * 2 + 2, "{text}");
}
/// Regression (#9741): `motion.study` takes degrees for angle keys, so `motion.list` must give
/// the keys back in those same units — feeding a listed study straight back to `motion.study`
/// must not turn a 90° key into 1.57°.
+21
View File
@@ -400,6 +400,27 @@ fn csv_and_json_round_trip() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A value a spreadsheet would run as a formula is exported as text, with a `'` first, and
/// imports back as it was (#12728). A plain number keeps its sign, and `;` makes a field quoted.
#[test]
fn csv_keeps_formulas_as_text_and_imports_them_back() {
let mut s = Session::default();
let rows = [("d3", "4", "plain"), ("a", "=d3", "+2 for the fit"), ("b", "=5", "'=kept"), ("c", "-5 mm", "@x"), ("e", "6", "one; two")];
for (name, expression, comment) in rows {
run(&mut s, "parameters.add", json!({"name": name, "expression": expression, "comment": comment}));
}
let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string();
for written in [",'=d3,", ",'+2 for the fit,", ",'=5,", ",''=kept,", ",'-5 mm,-5,'@x,", ",\"one; two\","] {
assert!(csv.contains(written), "{written}: {csv}");
}
let mut t = Session::default();
run(&mut t, "parameters.import", json!({"text": csv}));
for (name, _, comment) in rows {
let (before, after) = (s.doc.param(name), t.doc.param(name));
assert_eq!(after.map(|p| (p.expr.as_str(), p.comment.as_str())), before.map(|p| (p.expr.as_str(), comment)), "{name}: {csv}");
}
}
#[test]
fn csv_round_trips_comments_with_line_breaks() {
for comment in ["line one\nline two", "\r\nfirst, \"quoted\"\r\n\r\nlast\r\n", "one\rtwo"] {
+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");
}
}
}
+3 -2
View File
@@ -1205,8 +1205,9 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
let text = if format == BendTableFormat::Json {
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
} else {
// Always quote text fields: body/rule names may include commas, quotes or newlines.
let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
// Always quote text fields: body/rule names may include commas, quotes or newlines, or start
// as a spreadsheet formula does.
let quote = solidcraft_doc::csv::quoted;
let mut csv =
"body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm,included_angle_deg\r\n"
.to_string();
+11
View File
@@ -2069,6 +2069,17 @@ fn a_flange_bent_inside_keeps_the_base_sketchs_arcs_in_the_flat_pattern_and_the_
}
}
/// A rule or body name that starts as a spreadsheet formula does is written as text (#12728).
#[test]
fn bend_table_writes_a_formula_like_name_as_text() {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "=A1+1", "thickness": 2, "bend_radius": 3, "k_factor": 0.4}));
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20}));
let text = run(&mut s, "sheet.export_bend_table", json!({}))["text"].as_str().unwrap_or_default().to_string();
assert!(text.lines().nth(1).is_some_and(|row| row.contains(",\"'=A1+1\",2.000000000,")), "{text}");
}
#[test]
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
let mut s = Session::default();
+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.
+4 -1
View File
@@ -696,7 +696,10 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
Axis => {
if let Some(axis) = v.as_object() {
let mut axis = axis.clone();
if let Some(origin) = axis.get("origin").and_then(|o| m.make(o)) {
// An `{origin, dir}` axis without its origin passes through the world's,
// which the component may have moved.
let origin = axis.get("origin").cloned().or_else(|| axis.contains_key("dir").then(|| pt(Vec3::ZERO)));
if let Some(origin) = origin.and_then(|o| m.make(&o)) {
axis.insert("origin".into(), origin);
}
if let Some(dir) = axis.get("dir").and_then(vec3)
+32
View File
@@ -456,6 +456,38 @@ fn a_surface_revolve_about_a_picked_edge_of_a_moved_component() {
assert!(near(edge.0, line.0) && near(edge.1, line.1), "{edge:?} against {line:?}");
}
/// A revolve axis given only by its direction passes through the world origin in a moved
/// component, as one with `origin: [0, 0, 0]` does: solid and surface revolves, made and edited.
#[test]
fn an_axis_without_an_origin_passes_through_the_world_origin() {
let made = |command: &str, axis: Value| {
let mut s = moved_part();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Ring"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 0], "p1": [10, 5]}));
run(&mut s, "sketch.finish", json!({}));
let mut p = json!({"sketch": "Ring", "axis": axis, "name": "Turn"});
if command == "solid.revolve" {
p["operation"] = json!("new");
}
run(&mut s, command, p.clone());
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
let first = world_bbox(&s, &body);
// Edit Feature with the same axis given again.
run(&mut s, "timeline.redefine", json!({"feature": "Turn", "command": command, "params": p}));
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
(first, world_bbox(&s, &body))
};
for command in ["solid.revolve", "surface.revolve"] {
let (given, given_edit) = made(command, json!({"origin": [0, 0, 0], "dir": [0, 0, 1]}));
let (omitted, omitted_edit) = made(command, json!({"dir": [0, 0, 1]}));
assert!(near(given.0, omitted.0) && near(given.1, omitted.1), "{command}: {given:?} against {omitted:?}");
assert!(
near(given_edit.0, omitted_edit.0) && near(given_edit.1, omitted_edit.1),
"{command}, edited: {given_edit:?} against {omitted_edit:?}"
);
}
}
/// A new placement given to `timeline.redefine` is still a world point.
#[test]
fn a_new_primitive_placement_is_still_mapped_from_the_world() {
+41
View File
@@ -125,6 +125,47 @@ fn an_upstream_cut_that_splits_a_face_keeps_the_edge_that_bounded_it() {
assert!(solid_at(&s, [0.1, 15.0, 9.9]), "the left one is not");
}
/// Three pockets of a pattern open onto the block's front wall, and a channel at their depth
/// joins their floors into one face: the three edges between the wall and that floor share one
/// name, numbered by where they lie, and a chamfer keeps all three. With the channel shallower,
/// each pocket has its own floor again, and only the first pocket's edge lies between the wall
/// and the floor the names name. The chamfer asks for its edges again instead of chamfering
/// that one edge for all three (#13332); with the floors joined again, it finds all three.
#[test]
fn edges_numbered_between_two_faces_are_not_all_taken_for_the_one_left_there() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "channel_floor", "expression": "5 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "PocketSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3, -5], "p1": [9, 6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "PocketSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Pocket"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Pocket"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 12, "name": "Row"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ChannelSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3, 3], "p1": [33, 9]}));
run(&mut s, "sketch.finish", json!({}));
let channel = json!({"sketch": "ChannelSketch", "distance": 12, "start_offset": "channel_floor", "operation": "cut", "name": "Channel"});
run(&mut s, "solid.extrude", channel);
run(&mut s, "solid.chamfer", json!({"edges": [[6, 0, 5], [18, 0, 5], [30, 0, 5]], "distance": 1, "name": "Chamfer"}));
let (block, pocket) = (id(&s, "Block"), id(&s, "Pocket"));
let chamfer = s.doc.find_feature("Chamfer").cloned().unwrap();
let mut stored = chamfer.edge_names.clone();
stored.sort();
assert_eq!(stored, (1..=3).map(|k| format!("F{block}:side:l1|F{pocket}:top~{k}")).collect::<Vec<_>>());
// Inside the block just below each pocket's floor, at the front: cut away by a chamfer there.
let chamfered = |s: &Session| [6.0, 18.0, 30.0].map(|x| !solid_at(s, [x, 0.2, 4.8]));
assert_eq!(chamfered(&s), [true; 3]);
change(&mut s, "channel_floor", "6 mm");
let asked = reselect(&s, "Chamfer");
assert!(asked.as_ref().is_some_and(|name| chamfer.edge_names.contains(name)), "{asked:?}, {:?}", error(&s, "Chamfer"));
assert_eq!(chamfered(&s), [false; 3], "no pocket's edge is chamfered for another's");
change(&mut s, "channel_floor", "5 mm");
assert_eq!(error(&s, "Chamfer"), None);
assert_eq!(chamfered(&s), [true; 3]);
}
#[test]
fn a_blend_follows_its_named_edge_to_a_moved_lid_instead_of_the_body_below() {
for (command, size, removed) in [("solid.fillet", "radius", (4.0 - std::f64::consts::PI) * 5.0), ("solid.chamfer", "distance", 2.0 * 5.0)] {
+344
View File
@@ -0,0 +1,344 @@
//! Contract for `solid.align` in `cmd/features_more.rs` (#13314). Align adds a feature and returns
//! `add_feature`'s reply, a [`super::features::FeatureResult`].
//!
//! 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. Whether a body, a plane, a sketch or a joint origin
//! exists, whether a pick lies on a planar face, and which combinations of keys go together, stay
//! the handler's to check.
//!
//! Align reads some keys only in some cases, and the contract reads them in the same cases:
//!
//! - a side (`from` or `to`) that has a plane or a frame (`from_plane`, `from_frame`) is worked
//! out from that, and its point and face keys are not read (a call that gives both is refused by
//! the handler as a whole);
//! - otherwise a face that is a point (`from_face`) wins, and `from` is not read at all;
//! - otherwise `from` is read: `"origin"`, `{snap?, at}` or `[x, y, z]`.
//!
//! So the type is written by hand, in the handler's order, and a call that works today gets no
//! warning for a key the handler does not read. Where the handler reads a key of the wrong type as
//! if it were left out (`flip`, `angle`, a `from_face` that is not a point, a `snap` that is not
//! text), the contract refuses it: that is what warn and strict are for. `bodies` is optional in
//! the type and required in the schema, and so is one of `from`, `from_face`, `from_plane` and
//! `from_frame` (and the same for `to`), so that the handler's own message is what a caller sees.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::{Map, Value, json};
use super::check::whole;
use super::config::NameList;
use super::joints::JointOriginArg;
use super::motion::require;
use super::types::{AngleExpr, FeatureCommon, PlaneParam, Point3, SketchRef, StringList, feature_common};
use crate::cmd::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, sketch_frame_id, sketch_key};
use crate::params::{self, MAX_STRING_LIST};
/// The handler's message for `bodies` left out, `null` or empty.
const BODIES_MESSAGE: &str = "`bodies` must list the bodies to move";
/// The kinds of snap point `from` and `to` take.
const SNAPS: [&str; 4] = ["vertex", "edge_mid", "face_center", "circle_center"];
/// The `bodies` schema: one name, or a list of 1 to 10 000.
fn bodies_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The bodies to move, by name: one, or a list of 1 to 10 000.",
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": MAX_STRING_LIST}
]
})
}
/// A point of an Align side: `"origin"`, a snap `{snap?, at}` or `[x, y, z]`. Only for the schema:
/// whether it is read at all depends on the side's other keys, so [`AlignParams`] reads it.
struct SidePointSchema;
impl JsonSchema for SidePointSchema {
fn schema_name() -> Cow<'static, str> {
"AlignPoint".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let c = json!({"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9});
json_schema!({
"description": "A point: [x, y, z] in mm, the text \"origin\" (any case), or {\"snap\"?, \"at\"}: the nearest vertex, edge midpoint, planar face centre or circular edge centre of the model to the point `at`. {\"x\", \"y\", \"z\"} is not a point here.",
"anyOf": [
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
{"type": "string", "pattern": "^[Oo][Rr][Ii][Gg][Ii][Nn]$"},
{
"type": "object",
"properties": {
"snap": {"description": "What to snap to. Default: vertex.", "type": "string", "enum": SNAPS},
"at": {"description": "The point to snap near: [x, y, z] or {\"x\", \"y\", \"z\"}, in mm.", "anyOf": [
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
{"type": "object", "properties": {"x": c, "y": c, "z": c}, "required": ["x", "y", "z"]}
]}
},
"required": ["at"]
}
]
})
}
}
/// A frame of an Align side: a joint origin as `joint.create` takes one, or `{sketch, point|curve}`.
/// Only for the schema; [`AlignParams`] reads it with [`frame_problem`].
struct FrameSchema;
impl JsonSchema for FrameSchema {
fn schema_name() -> Cow<'static, str> {
"AlignFrame".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let sketch = generator.subschema_for::<SketchRef>();
let origin = generator.subschema_for::<JointOriginArg>();
let id = json!({"type": "string", "minLength": 1, "maxLength": MAX_SKETCH_ID});
json_schema!({
"description": "A frame that is found again whenever the design changes, so the bodies follow it: a sketch point, or the centre of a sketch circle, arc or ellipse, as {\"sketch\", \"point\" | \"curve\"} (z along the sketch's normal); or a joint origin as joint.create takes one: {face | circle | edge | point: [x, y, z]}, a face's name, or the name of a joint origin. On the bodies' own component, which must hold all of them and be placed once.",
"anyOf": [
{
"type": "object",
"properties": {
"sketch": sketch,
"point": {"description": "A sketch point's id: 1 to 256 characters. Give this or `curve`.", "allOf": [id]},
"curve": {"description": "A sketch circle's, arc's or ellipse's id: 1 to 256 characters. Give this or `point`.", "allOf": [id]}
},
"required": ["sketch"],
"anyOf": [
{"required": ["point"], "not": {"required": ["curve"]}},
{"required": ["curve"], "not": {"required": ["point"]}}
]
},
{"allOf": [origin], "not": {"type": "object", "required": ["sketch"]}}
]
})
}
}
/// `axes`: three booleans, at least one true. Only for the schema.
struct AxesSchema;
impl JsonSchema for AxesSchema {
fn schema_name() -> Cow<'static, str> {
"AlignAxes".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Which world axes the move follows: [x, y, z] booleans, at least one true.",
"type": "array",
"items": {"type": "boolean"},
"minItems": 3,
"maxItems": 3,
"contains": {"const": true}
})
}
}
/// `bodies` is required, and so is one key for each side.
fn align_schema(schema: &mut Schema) {
require(schema, "bodies");
// Each side needs one of its four keys; the handler says which, in its own words.
let side = |keys: [&str; 4]| json!({"anyOf": keys.iter().map(|k| json!({"required": [k]})).collect::<Vec<_>>()});
let sides = [side(["from", "from_face", "from_plane", "from_frame"]), side(["to", "to_face", "to_plane", "to_frame"])];
match schema.get_mut("allOf") {
Some(Value::Array(all)) => all.extend(sides),
_ => {
schema.insert("allOf".into(), Value::Array(sides.into()));
}
}
}
/// Move bodies so that a point, face, plane or frame on them meets another one, as a feature in
/// the timeline (one undo step). Each side is one of: a point (`from`, `to`), a planar face
/// picked by a point on it (`from_face`, `to_face`), a plane (`from_plane`, `to_plane`) or a frame
/// (`from_frame`, `to_frame`); give one key for each side. A plane or a frame is worked out again
/// whenever the design changes, so the bodies follow it. Faces, planes and frames also turn the
/// bodies so that they meet (the first side's face against the second's, or with `flip` side by
/// side); a point onto a plane moves along the plane's normal. `axes` keeps the move to some
/// world axes and only goes with two points. Align makes no body, so `body_name` and `body_names`
/// do nothing here (as for Remove), and `participants` changes nothing in the result.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = align_schema)]
pub struct AlignParams {
// Optional in the type and required in the schema (`align_schema`): left out, `null` or
// empty, the handler refuses the call with its own message.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "bodies_schema")]
pub bodies: Option<NameList>,
/// Where the move starts, on the bodies. Not read when `from_face` is a point; not with
/// `from_plane` or `from_frame` (the handler refuses both).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SidePointSchema")]
pub from: Option<Value>,
/// A point on a planar face of the bodies to start from: the bodies turn so that the face
/// meets the target's. When it is a point, `from` is not read. Not with `from_plane` or
/// `from_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub from_face: Option<Value>,
/// A plane to start from, kept as a plane so the bodies follow it when it moves; the bodies
/// turn so that it meets the target. Not with `from`, `from_face` or `from_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaneParam")]
pub from_plane: Option<Value>,
/// A frame to start from, in place of the point, face or plane; the bodies turn so that it
/// meets the target. Not with `from`, `from_face` or `from_plane`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "FrameSchema")]
pub from_frame: Option<Value>,
/// Where the bodies go. Not read when `to_face` is a point; not with `to_plane` or
/// `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SidePointSchema")]
pub to: Option<Value>,
/// A point on a planar face to go to. When it is a point, `to` is not read. Not with
/// `to_plane` or `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub to_face: Option<Value>,
/// A plane to go to, which may move later (the bodies follow); a point or a face goes onto
/// it along its normal. Not with `to`, `to_face` or `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaneParam")]
pub to_plane: Option<Value>,
/// A frame to go to, in place of the point, face or plane. It must not be on a body that
/// moves. Not with `to`, `to_face` or `to_plane`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "FrameSchema")]
pub to_frame: Option<Value>,
/// With faces, planes or frames: true turns the bodies so that the two normals point the same
/// way, instead of facing each other. Default: false.
#[serde(default)]
pub flip: Option<bool>,
/// Which world axes the offset moves along, with a point on each side: [x, y, z] booleans,
/// at least one true. Default: all three. Refused with a face, plane or frame.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "AxesSchema")]
pub axes: Option<[bool; 3]>,
/// A turn about the target's direction (a face's normal, a plane's normal or a frame's z)
/// after the move, in degrees or an expression. Needs a face, a plane or a frame as the
/// target. Default: none.
#[serde(default)]
pub angle: Option<AngleExpr>,
#[serde(flatten)]
pub common: FeatureCommon,
}
/// `bodies` as the handler reads it: names, at least one; `null` and a missing key are none.
fn bodies(m: &Map<String, Value>) -> Result<NameList, String> {
let names = match m.get("bodies") {
None | Some(Value::Null) => NameList::default(),
// Each of its messages starts with "must": the key goes in front, as `string_list` has it.
Some(v) => NameList::deserialize(v).map_err(|e| whole(format!("`bodies` {e}")))?,
};
if names.0.is_empty() { Err(whole(BODIES_MESSAGE)) } else { Ok(names) }
}
/// `key`, a plane, when it is given (`null` included: the handler reads it as a plane and
/// refuses it).
fn plane(m: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
// The reader's own message is a whole sentence, marked as such.
m.get(key).map(|v| PlaneParam::deserialize(v).map(|p| p.0).map_err(|e| e.to_string())).transpose()
}
/// What is wrong with the frame `v` of `key` (`from_frame`), as the handler says it, marked
/// whole, or `None`.
fn frame_problem(v: &Value, key: &str) -> Option<String> {
if let Some(sketch) = v.get("sketch") {
if sketch_key(Some(sketch)).is_none() {
return Some(whole(format!("`{key}` must be a sketch id or name")));
}
return match sketch_frame_id(v) {
Some(SketchFrameId::Point(_) | SketchFrameId::Curve(_)) => None,
None => Some(whole(format!("`{key}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
};
}
// A joint origin, which the handler reads as `joint.create` reads one.
JointOriginArg::deserialize(v).err().map(|e| e.to_string())
}
/// `<key>_frame`, when it is given and not `null`.
fn frame(m: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
let frame_key = format!("{key}_frame");
match m.get(&frame_key) {
None | Some(Value::Null) => Ok(None),
// The handler refuses a frame given beside the side's other keys before it reads the frame.
Some(v) if [key.to_string(), format!("{key}_face"), format!("{key}_plane")].iter().any(|k| m.contains_key(k)) => Ok(Some(v.clone())),
Some(v) => frame_problem(v, &frame_key).map_or_else(|| Ok(Some(v.clone())), Err),
}
}
/// The point and face of the side `pk` (`from`), read as the handler reads them: not at all when
/// the side has a plane or a frame (`held`); the face if it is a point, and then `from` is not
/// read; else `from`.
fn side(m: &Map<String, Value>, pk: &str, held: bool) -> Result<(Option<Value>, Option<Value>), String> {
if held {
return Ok((None, None));
}
let fk = format!("{pk}_face");
match m.get(&fk) {
None | Some(Value::Null) => {}
Some(v) if params::vec3(v).is_some() => return Ok((None, Some(v.clone()))),
Some(_) => return Err(whole(format!("`{fk}` must be a point [x, y, z] of numbers"))),
}
let v = m.get(pk);
match crate::cmd::side_point(v, pk, &fk).map_err(whole)? {
SidePoint::Snap { .. } => {
// The handler reads a `snap` that is not text as a vertex; the word is its to look up.
if v.and_then(|o| o.get("snap")).is_some_and(|s| !s.is_null() && !s.is_string()) {
return Err(whole(format!("`{pk}.snap` must be {}", SNAPS.join(", "))));
}
}
SidePoint::Origin | SidePoint::Point(_) => {}
}
Ok((v.cloned(), None))
}
impl<'de> Deserialize<'de> for AlignParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = Map::<String, Value>::deserialize(d)?;
let fail = D::Error::custom;
// In the handler's order: the bodies, the planes, the frames, the two sides' points, the
// angle and the axes; then `add_feature`'s keys.
let bodies = bodies(&m).map_err(fail)?;
let (from_plane, to_plane) = (plane(&m, "from_plane").map_err(fail)?, plane(&m, "to_plane").map_err(fail)?);
let (from_frame, to_frame) = (frame(&m, "from").map_err(fail)?, frame(&m, "to").map_err(fail)?);
let (from, from_face) = side(&m, "from", from_plane.is_some() || from_frame.is_some()).map_err(fail)?;
let (to, to_face) = side(&m, "to", to_plane.is_some() || to_frame.is_some()).map_err(fail)?;
let angle = match m.get("angle") {
None | Some(Value::Null) => None,
Some(v) => Some(AngleExpr::deserialize(v).map_err(|e| fail(whole(format!("`angle` {e}"))))?),
};
let axes = match m.get("axes") {
None | Some(Value::Null) => None,
Some(v) => Some(axes_mask(v).map_err(|why| fail(whole(why)))?),
};
let flip = match m.get("flip") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => return Err(fail(whole("`flip` must be true or false"))),
};
// `add_feature` reads `body_names` and `participants` as `string_list` does, whose messages
// are `NameList`'s, not `FeatureCommon`'s; they are read here, and the rest go to it.
let mut m = m;
let mut lists = Vec::new();
for key in ["participants", "body_names"] {
if let Some(v) = m.remove(key).filter(|v| !v.is_null()) {
lists.push(StringList(NameList::deserialize(v).map_err(|e| fail(whole(format!("`{key}` {e}"))))?.0));
} else {
lists.push(StringList::default());
}
}
let mut common = feature_common::<_, FeatureCommon>(Value::Object(m)).map_err(|e| fail(e.to_string()))?;
let (body_names, participants) = (lists.pop().filter(|l| !l.0.is_empty()), lists.pop().filter(|l| !l.0.is_empty()));
(common.body_names, common.participants) = (body_names, participants);
Ok(AlignParams { bodies: Some(bodies), from, from_face, from_plane, from_frame, to, to_face, to_plane, to_frame, flip, axes, angle, common })
}
}
#[cfg(test)]
#[path = "align_tests.rs"]
mod tests;
+329
View File
@@ -0,0 +1,329 @@
use serde_json::{Value, json};
use super::AlignParams;
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
/// Box `A` (10 x 10 x 10 at the origin) and `B` (10 x 20 x 10 at (50, 0, 20)), and sketch `S` on
/// XY with a circle `ring` at (0, 5) and a point `p` at (20, 0).
fn scene(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"})),
("solid.box", json!({"length": 10, "width": 20, "height": 10, "corner": [50, 0, 20], "body_name": "B", "operation": "new"})),
("sketch.create", json!({"plane": "XY", "name": "S"})),
("sketch.circle.center", json!({"center": [0, 5], "radius": 3, "id": "ring"})),
("sketch.point", json!({"point": [20, 0], "id": "p"})),
("sketch.finish", json!({})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
}
s
}
/// `base` for body `B` with `extra`'s keys added.
fn call(extra: Value) -> Value {
let mut p = json!({"bodies": ["B"]});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
}
fn messages(p: &Value) -> Vec<String> {
issues("solid.align", p).into_iter().map(|i| i.message).collect()
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<AlignParams>(),
&[
"angle",
"axes",
"bodies",
"body_name",
"body_names",
"flip",
"from",
"from_face",
"from_frame",
"from_plane",
"name",
"participants",
"to",
"to_face",
"to_frame",
"to_plane",
],
);
let schema = input_schema_for::<AlignParams>();
assert_eq!(schema.as_value()["required"], json!(["bodies"]));
}
/// Calls that fail today get the same message in strict, on a design with the bodies, a sketch
/// and its geometry (the handler's first check is that the bodies exist).
#[test]
fn strict_keeps_todays_errors() {
let long = "q".repeat(300);
let cases = [
json!({}),
json!({"bodies": null, "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": [], "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": 5, "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": ["B", 5], "from": [0, 0, 0], "to": [1, 1, 1]}),
call(json!({})),
call(json!({"from": [0, 0, 0]})),
call(json!({"to": [0, 0, 0]})),
call(json!({"from": [0, 0], "to": [0, 0, 0]})),
call(json!({"from": null, "to": [0, 0, 0]})),
call(json!({"from": "nowhere", "to": [0, 0, 0]})),
call(json!({"from": {"x": 1, "y": 2, "z": 3}, "to": [0, 0, 0]})),
call(json!({"from": {"snap": "vertex"}, "to": [0, 0, 0]})),
call(json!({"from": {"at": "here"}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to": {"at": [1, 2]}})),
call(json!({"from": [0, 0, 0], "to": "centre"})),
call(json!({"from": "origin", "to_face": null})),
call(json!({"from_plane": null, "to": [0, 0, 0]})),
call(json!({"from_plane": true, "to": [0, 0, 0]})),
call(json!({"from_plane": -1, "to": [0, 0, 0]})),
call(json!({"from_plane": {"origin": [0, 0]}, "to": [0, 0, 0]})),
call(json!({"from_plane": {}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to_plane": [1]})),
call(json!({"from_frame": 5, "to": [0, 0, 0]})),
// A frame beside the side's other keys is refused as "not both" before it is read.
call(json!({"from_frame": 5, "from": [0, 0, 0], "to": [1, 1, 1]})),
call(json!({"from_plane": "XY", "from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {}}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": true, "point": "p"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": null, "point": "p"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "p", "curve": "ring"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": ""}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": long}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to_frame": {"sketch": "S", "point": 5}})),
call(json!({"from": [0, 0, 0], "to_frame": {"face": [1, 2]}})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": "x"})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [true, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [true, 1, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [false, false, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": 5})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": ["Nope"]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": ["B", false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": 5, "body_names": 6})),
call(json!({"from": [0], "to": [1]})),
call(json!({"from_plane": 7.5, "to_plane": true})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "body_names": [7]})),
];
for p in &cases {
let error = |level: ParamCheck| scene(level).execute("solid.align", p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
// The ones that fail before any body is looked up, with the shared helper's session.
assert_strict_keeps_todays_errors(&[
("solid.align", json!({})),
("solid.align", json!({"bodies": []})),
("solid.align", json!({"bodies": 5})),
("solid.align", json!({"bodies": [1]})),
]);
}
/// A side that has a plane or a frame is read from that, and one that has a face that is a point
/// is read from the face: the keys the handler does not read in that case are not checked, so a
/// call that works today gets no warning.
#[test]
fn keys_the_handler_does_not_read_are_not_checked() {
let calls = [
// `from` is not read when `from_face` is a point.
call(json!({"from_face": [55, 10, 20], "from": "garbage", "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "from": null, "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "from": {"snap": 5}, "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 0], "to": 17})),
// A null face is a missing one.
call(json!({"from_face": null, "from": [50, 0, 20], "to": [0, 0, 0]})),
// A null frame is a missing one; so is a face that is null beside a frame.
call(json!({"from_frame": null, "from": [50, 0, 20], "to": "origin"})),
call(json!({"from": [50, 0, 20], "to_frame": null, "to": [1, 2, 3]})),
// A snap with no `snap` snaps to a vertex; the object's other keys are not read.
call(json!({"from": {"at": [50, 0, 20], "note": 1}, "to": {"snap": null, "at": [0, 0, 0]}})),
// `axes` and `angle` may be `null`.
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": null, "angle": null, "flip": null})),
];
for p in &calls {
assert!(scene(ParamCheck::Off).execute("solid.align", p).is_ok(), "{p} works today");
assert!(messages(p).is_empty(), "{p}: {:?}", messages(p));
let reply = scene(ParamCheck::Warn).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(scene(ParamCheck::Strict).execute("solid.align", p).is_ok(), "{p}");
}
// With a plane or a frame the point and face are not read: the handler refuses the call, in
// its own words, and the contract leaves that to it.
for p in [
call(json!({"from_plane": "XY", "from": "garbage", "to": [0, 0, 0]})),
call(json!({"from_plane": "XY", "from_face": "garbage", "to": [0, 0, 0]})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "from": 5, "to": [0, 0, 0]})),
] {
assert!(messages(&p).is_empty(), "{p}: {:?}", messages(&p));
let error = |level: ParamCheck| scene(level).execute("solid.align", &p).err().map(|e| e.to_string());
assert!(error(ParamCheck::Off).is_some_and(|e| e.contains("not both")), "{p}");
assert_eq!(error(ParamCheck::Strict), error(ParamCheck::Off), "{p}");
}
}
/// Keys of the wrong type that the handler reads as left out are refused by the contract: in Warn
/// the call still works, with a warning naming the key.
#[test]
fn the_contract_refuses_what_the_handler_ignores() {
let cases = [
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "flip": "yes"})), "flip"),
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "angle": true})), "angle"),
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "angle": []})), "angle"),
(call(json!({"from_face": "junk", "from": [50, 0, 20], "to": [0, 0, 0]})), "from_face"),
(call(json!({"from": {"snap": 5, "at": [50, 0, 20]}, "to": [0, 0, 0]})), "from.snap"),
];
for (p, key) in cases {
assert!(scene(ParamCheck::Off).execute("solid.align", &p).is_ok(), "{p} works today");
let warned = scene(ParamCheck::Warn).execute("solid.align", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(warned["param_warnings"].to_string().contains(key), "{p}: {warned}");
let error = scene(ParamCheck::Strict).execute("solid.align", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains(&format!("`{key}`")), "{p}: {error}");
}
}
/// The messages the check gives, which are the handler's.
#[test]
fn the_check_names_what_is_wrong_with_a_key() {
let go = |extra: Value| messages(&call(extra));
assert_eq!(messages(&json!({})), ["`bodies` must list the bodies to move"]);
assert_eq!(messages(&json!({"bodies": [5]})), ["`bodies` must list names; the entry 1 is 5"]);
assert_eq!(go(json!({})), ["give `from`, `from_face` or `from_plane` ([x, y, z], \"origin\", or a plane)"]);
assert_eq!(go(json!({"from": "origin"})), ["give `to`, `to_face` or `to_plane` ([x, y, z], \"origin\", or a plane)"]);
assert_eq!(go(json!({"from": "ORIGIN", "to": {"snap": "vertex"}})), ["`to.at` must be [x, y, z]"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "axes": [false, false, false]})), ["`axes` must enable at least one of X, Y and Z"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "axes": [true]})), ["`axes` must be [x, y, z] booleans"]);
assert_eq!(
go(json!({"from": "origin", "to": "origin", "angle": "x".repeat(5000)})),
["`angle` must be an expression of at most 4096 characters"]
);
assert_eq!(go(json!({"from": "origin", "to": "origin", "participants": 5})), ["`participants` must be a list of names; it is 5"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "bodies2": 1})).len(), 1);
assert_eq!(go(json!({"from": "origin", "to": "origin", "flipp": true})), ["unknown key `flipp`; did you mean `flip`?"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "flip": 1})), ["`flip` must be true or false"]);
}
/// The schema takes what the handler takes where the keys alone decide it. Each call is checked
/// against the schema and against the handler, with the check off.
#[test]
fn the_schema_takes_what_the_handler_takes() {
let schema = input_schema_for::<AlignParams>();
let schema = schema.as_value();
let ring = json!({"sketch": "S", "curve": "ring"});
let calls = [
call(json!({"from": [50, 0, 20], "to": [0, 0, 0]})),
call(json!({"from": "Origin", "to": {"snap": "vertex", "at": [0, 0, 0]}})),
call(json!({"from": {"at": {"x": 50, "y": 0, "z": 20}}, "to": [0, 0, 0], "axes": [false, false, true]})),
call(json!({"from_face": [55, 10, 20], "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 0], "flip": true, "angle": "30 deg"})),
call(json!({"from_plane": {"origin": [0, 0, 5], "normal": [0, 0, 1]}, "to": [0, 0, 7]})),
call(json!({"from_face": {"x": 55, "y": 10, "z": 20}, "to_plane": "XY"})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {"origin": [55, 10, 20], "z": [0, 0, -1]}}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5]})),
json!({"bodies": "B", "from": [50, 0, 20], "to": [0, 0, 0], "name": "Move", "participants": ["B"]}),
json!({}),
json!({"bodies": ["B"]}),
json!({"bodies": [], "from": [50, 0, 20], "to": [0, 0, 0]}),
call(json!({"from": [50, 0, 20]})),
call(json!({"to": [50, 0, 20]})),
call(json!({"from": [50, 0], "to": [0, 0, 0]})),
call(json!({"from": {"x": 50, "y": 0, "z": 20}, "to": [0, 0, 0]})),
call(json!({"from": {"snap": "vertex"}, "to": [0, 0, 0]})),
call(json!({"from_plane": 7.5, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "p", "curve": "ring"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": ""}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "q".repeat(257)}, "to": [0, 0, 0]})),
call(json!({"from_frame": 5, "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": [true]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "flip": "yes"})),
];
for p in &calls {
// The schema takes a call when the handler does and the check has nothing to say (a
// `flip` that is not true or false runs today, and is a warning).
let takes = scene(ParamCheck::Off).execute("solid.align", p).is_ok();
assert_eq!(conforms(schema, schema, p), takes && messages(p).is_empty(), "{p}: {:?}", messages(p));
}
}
/// Each form of the command, in Warn: a reply that matches the output schema and carries no
/// warning, since Warn runs a call the contract refuses as well. The same calls pass strict.
#[test]
fn replies_match_the_output_schema() {
let ring = json!({"sketch": "S", "curve": "ring"});
let calls = [
call(json!({"from": [50, 0, 20], "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": [true, false, true]})),
call(json!({"from": "ORIGIN", "to": {"snap": "vertex", "at": [10, 20, 30]}})),
call(json!({"from": {"snap": "edge_mid", "at": [50, 0, 25]}, "to": {"at": [0, 0, 0]}})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 10]})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 10], "flip": true, "angle": "30 deg"})),
call(json!({"from_face": {"x": 55, "y": 10, "z": 20}, "to_plane": "XY"})),
call(json!({"from_plane": {"origin": [0, 0, 5], "normal": [0, 0, 1]}, "to": [0, 0, 7]})),
call(json!({"from_plane": "XY", "to_plane": {"origin": [0, 0, 5], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5], "flip": true})),
call(json!({"from_frame": {"point": [55, 10, 20]}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {"origin": [55, 10, 20], "z": [0, 0, -1]}}, "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to_frame": {"sketch": "S", "point": "p"}})),
json!({"bodies": "B", "from": [50, 0, 20], "to": [0, 0, 0], "name": "Move B", "participants": ["B", "A"]}),
json!({"bodies": ["A", "B"], "from": [0, 0, 0], "to": [0, 0, 5], "body_name": "unused", "participants": "*"}),
];
let schema = output_schema_for::<FeatureResult>();
for p in &calls {
let reply = scene(ParamCheck::Warn).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{p}: {reply}");
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(messages(p).is_empty(), "{p}: {:?}", messages(p));
let strict = scene(ParamCheck::Strict).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
// The replies are the same but for the time it took.
let same = |mut r: Value| {
r.as_object_mut().map(|o| o.remove("ms"));
r
};
let off = scene(ParamCheck::Off).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert_eq!(same(strict), same(reply.clone()), "{p}");
assert_eq!(same(off), same(reply), "{p}");
}
// A misspelt key still runs in Warn, and says so, with a reply that matches.
let p = call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axis": [true, true, true]}));
let warned = scene(ParamCheck::Warn).execute("solid.align", &p).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `axis`; did you mean `axes`?"]), "{warned}");
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
}
/// The patterns and schemas the tests above can't read (`conforms` skips patterns and `contains`).
#[test]
fn the_schema_text_is_pinned() {
let schema = input_schema_for::<AlignParams>();
let all = schema.as_value().to_string();
assert!(all.contains(r#""pattern":"^[Oo][Rr][Ii][Gg][Ii][Nn]$""#), "{all}");
assert!(all.contains(r#""contains":{"const":true}"#), "{all}");
assert!(all.contains(r#""maxLength":256"#), "{all}");
assert!(schema.as_value()["properties"]["from"]["description"].is_string());
for key in ["from", "to"] {
let any = format!(
r#"{{"anyOf":[{{"required":["{key}"]}},{{"required":["{key}_face"]}},{{"required":["{key}_plane"]}},{{"required":["{key}_frame"]}}]}}"#
);
assert!(all.contains(&any), "{key}: {all}");
}
// `origin` in any case is what the handler reads, and nothing around it.
let origin = |t: &str| scene(ParamCheck::Off).execute("solid.align", &call(json!({"from": t, "to": [0, 0, 0]}))).is_ok();
assert!(origin("origin") && origin("OrIgIn") && !origin(" origin") && !origin("origin\n"));
}
+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,
+11 -1
View File
@@ -19,6 +19,15 @@ pub struct AssistantParams {}
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssistantResult {}
/// Command search is UI-owned and takes no parameters.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PaletteParams {}
/// Headless sessions refuse to open command search and produce no result.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PaletteResult {}
/// `edit.repeat` takes no parameters: it starts the last tool with its defaults.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -1519,7 +1528,7 @@ mod tests {
use super::{
AssistantParams, CheckpointParams, CheckpointResult, FeatureKeys, FeatureRef, FeatureSelection, FolderId, FolderIds, MarkerPosition, NewName,
RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
PaletteParams, RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult, UndoParams, UndoResult,
@@ -1537,6 +1546,7 @@ mod tests {
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<RepeatParams>(), &[]);
assert_keys(input_schema_for::<AssistantParams>(), &[]);
assert_keys(input_schema_for::<PaletteParams>(), &[]);
assert_keys(input_schema_for::<UndoParams>(), &[]);
assert_keys(input_schema_for::<RedoParams>(), &[]);
assert_keys(input_schema_for::<CheckpointParams>(), &[]);
+73
View File
@@ -18,6 +18,7 @@ use super::types::{
OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint,
TouchingParam, feature_common, present,
};
use super::types::{RevolveAxis, RevolveConventionParam, RevolveDirection, RevolveToPoint};
use crate::params::MAX_STRING_LIST;
/// Add a box primitive.
@@ -181,6 +182,78 @@ pub struct ExtrudeParams {
pub thickness2: Option<LengthExpr>,
}
/// Revolve sketch profiles (or a planar body face) about an axis into a solid.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RevolveParams {
/// The axis to turn about.
pub axis: RevolveAxis,
/// How far to turn: more than 0 and at most 360 degrees. With `angle2`, the angle on the
/// first side, the two adding up to the turn; with `direction: symmetric`, the whole turn,
/// split either side. Default: 360 deg. Not used when `to` is given, though it must still
/// evaluate.
#[serde(default)]
pub angle: Option<AngleExpr>,
/// Two Sides: the angle on the other side, turned back from the profile. Not used when `to`
/// is given; used instead of `direction: symmetric` when both are given.
#[serde(default)]
pub angle2: Option<AngleExpr>,
/// `symmetric` splits the angle evenly either side of the profile, whichever way the axis
/// points. The handler ignores every other value.
#[serde(default)]
pub direction: Option<RevolveDirection>,
/// When true, the same as `direction: symmetric`.
#[serde(default)]
pub symmetric: Option<bool>,
/// Where the angle starts. Default: sketch_normal.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "RevolveConventionParam")]
pub convention: Option<RevolveConventionParam>,
/// Turn until this face, vertex or point (To Object). Takes the place of `angle`, `angle2`
/// and `direction`.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "RevolveToPoint")]
pub to: Option<RevolveToPoint>,
/// The sketch to revolve, by id or name. Default: the active sketch, else the last one.
/// Ignored when `face` is given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "IdOrName")]
pub sketch: Option<IdOrName>,
/// Which of the sketch's profiles to revolve. Default: all of them. Ignored when `face` is
/// given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ProfilesParam")]
pub profiles: Option<ProfilesParam>,
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
#[serde(default)]
pub exact_regions: Option<bool>,
/// A point on a planar body face to revolve instead of a sketch profile.
#[serde(default)]
pub face: Option<Point3>,
/// The body whose face to use, where faces of several bodies meet at `face`.
#[serde(default)]
pub face_body: Option<String>,
/// With `face`, among placed instances: the body whose placement `face` is read in (read by
/// the engine's frame mapping, before the command runs). Default: `face_body`, else the
/// body nearest the point.
#[serde(default)]
pub body: Option<String>,
/// How the revolve combines with existing bodies. Default: new.
#[serde(default)]
pub operation: Option<OperationParam>,
/// The bodies a join, cut or intersect works on. Default for a join from a body's face, or
/// from a sketch on one: that body. Explicit `targets` or `participants` replace it.
#[serde(default)]
pub targets: Option<StringList>,
/// What a join makes of pieces that share no face. Default: separate.
#[serde(default)]
pub touching: Option<TouchingParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Something a feature's result tells the user without stopping them, with an action to take.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FeatureNotice {
+167 -1
View File
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use super::{
BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams, ScaleParams, SphereParams, TorusParams,
};
use super::{SurfaceSweepParams, SweepParams};
use crate::Session;
@@ -52,6 +52,31 @@ fn schemas_and_the_key_check_agree() {
"touching",
],
);
assert_keys(
input_schema_for::<RevolveParams>(),
&[
"angle",
"angle2",
"axis",
"body",
"body_name",
"body_names",
"convention",
"direction",
"exact_regions",
"face",
"face_body",
"name",
"operation",
"participants",
"profiles",
"sketch",
"symmetric",
"targets",
"to",
"touching",
],
);
assert_keys(
input_schema_for::<CylinderParams>(),
&["axis", "base", "body_name", "body_names", "diameter", "height", "name", "operation", "participants", "radius", "references", "targets"],
@@ -165,6 +190,41 @@ fn strict_keeps_todays_errors() {
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
("solid.extrude", json!({"distance": " "})),
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
("solid.revolve", json!({"axis": "y", "operation": "bogus"})),
("solid.revolve", json!({"axis": "y", "operation": "auto"})),
("solid.revolve", json!({"axis": "y", "angle": "no_such_parameter"})),
("solid.revolve", json!({"axis": "y", "angle2": "no_such_parameter"})),
("solid.revolve", json!({"axis": "y", "to": [1, 2]})),
("solid.revolve", json!({"axis": "y", "to": null})),
("solid.revolve", json!({"axis": "y", "convention": "per_side"})),
("solid.revolve", json!({"axis": "y", "convention": "SKETCH_NORMAL"})),
("solid.revolve", json!({"axis": "y", "convention": null})),
("solid.revolve", json!({"axis": "y", "sketch": true})),
("solid.revolve", json!({"axis": "y", "sketch": null})),
("solid.revolve", json!({"axis": "y", "sketch": "Missing"})),
("solid.revolve", json!({"axis": "y", "profiles": []})),
("solid.revolve", json!({"axis": "y", "profiles": [{"point": [1]}]})),
("solid.revolve", json!({"axis": "y", "profiles": [true]})),
("solid.revolve", json!({"axis": "y", "participants": ["Missing"]})),
("solid.revolve", json!({"axis": null})),
("solid.revolve", json!({"axis": 5})),
("solid.revolve", json!({"axis": [0, 1, 0]})),
("solid.revolve", json!({"axis": true})),
("solid.revolve", json!({"axis": {}})),
("solid.revolve", json!({"axis": {"origin": [0, 0, 0]}})),
("solid.revolve", json!({"axis": {"dir": [0, 1]}})),
("solid.revolve", json!({"axis": {"dir": "y"}})),
("solid.revolve", json!({"axis": {"origin": [0, 0], "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": "o", "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": null, "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": ["no_such_parameter", 0, 0], "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"type": "world"}})),
("solid.revolve", json!({"axis": {"type": "world", "axis": "y"}})),
("solid.revolve", json!({"axis": {"type": "world", "axis": 5}})),
("solid.revolve", json!({"axis": {"edge": "x"}})),
("solid.revolve", json!({"axis": {"face": [1, 2]}})),
("solid.revolve", json!({"axis": {"type": "edge"}})),
("solid.revolve", json!({"axis": {"edge": [1, 2, 3]}})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": "W"})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": " w "})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": [0, 0, 0]})),
@@ -866,3 +926,109 @@ fn converted_primitives_and_body_commands_reply_with_feature_result() {
let (changed, removed) = (r["changed_bodies"].as_array().cloned(), r["removed_bodies"].as_array().cloned());
assert!(changed.is_some_and(|c| c.contains(&json!("Bounding"))) && removed.is_some_and(|r| r.contains(&json!("Peg"))), "{r}");
}
/// A 2 x 10 profile standing off the Y axis in the XY plane, a box to join to or turn a face of,
/// and a construction axis to turn about.
fn revolve_rig(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("sketch.create", json!({"name": "Profile", "plane": "XY"})),
("sketch.rectangle.two_point", json!({"p0": [2, 0], "p1": [4, 10]})),
("sketch.finish", json!({})),
("solid.box", json!({"corner": [20, 0, 0], "length": 5, "width": 5, "height": 5, "body_name": "Block"})),
("construct.axis.two_points", json!({"a": {"point": [0, 0, 0]}, "b": {"point": [0, 1, 0]}, "name": "Spindle"})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
s
}
/// Every form a revolve call takes.
fn revolve_calls() -> Vec<Value> {
vec![
json!({"sketch": "Profile", "axis": "y", "angle": "90 deg", "operation": "new", "name": "A", "body_name": "A"}),
json!({"axis": "Y", "angle": 45, "direction": "Symmetric", "body_names": ["B"]}),
json!({"sketch": 1, "axis": {"origin": ["0 mm", 0, 0], "dir": {"x": 0, "y": 1, "z": 0}}, "symmetric": true, "convention": "legacy_axis_start"}),
json!({"axis": {"dir": [0, 1, 0]}, "angle": "30 deg", "angle2": "15 deg", "profiles": "all", "exact_regions": true}),
json!({"axis": {"type": "world", "axis": "Y"}, "angle": 20, "profiles": [0], "convention": "sketch_normal"}),
json!({"axis": "Spindle", "angle": 10, "direction": null, "symmetric": null, "touching": null}),
json!({"axis": {"type": "sketch_line", "dir": [0, 1, 0]}, "to": [0, 5, 3], "angle": 5, "angle2": null}),
json!({"axis": "y", "angle": "60 deg", "operation": "union", "targets": ["Block"], "participants": [], "touching": "one_body"}),
json!({"axis": "y", "angle": 15, "operation": "join", "participants": ["A"], "name": "Join"}),
json!({"axis": "y", "angle": 15, "operation": "cut", "targets": "A"}),
json!({"axis": {"origin": [0, 0, 5], "dir": [0, 1, 0]}, "face": [22.5, 2.5, 5], "face_body": "Block", "body": "Block", "angle": 90}),
]
}
/// `solid.revolve` replies with `FeatureResult` in each of its forms, its contract taking each: in
/// Warn a contract that refused one would still run it, so the replies are checked for warnings.
#[test]
fn revolve_replies_match_the_output_schema_and_forms_are_accepted() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let mut s = revolve_rig(ParamCheck::Warn);
for p in revolve_calls() {
let r = s.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
}
// Warn reports what the contract refuses and runs the call anyway.
let typo = s.execute("solid.revolve", &json!({"axis": "y", "angel": 10})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(typo["param_warnings"], json!(["unknown key `angel`"]));
assert!(conforms(schema, schema, &typo), "{typo}");
}
/// The check changes nothing about the reply: each call gives the same JSON with the check off
/// and in strict (`ms`, the time it took, apart), and its keys are the ones the command always
/// answered with.
#[test]
fn revolve_reply_keeps_its_old_shape_whatever_the_check() {
let (mut off, mut strict) = (revolve_rig(ParamCheck::Off), revolve_rig(ParamCheck::Strict));
for p in revolve_calls() {
let run = |s: &mut Session| {
let mut r = s.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let keys: BTreeSet<String> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
assert!(r["ms"].is_null() || r["ms"].is_number(), "{r}");
r["ms"] = Value::Null;
(r, keys)
};
let (a, a_keys) = run(&mut off);
let (b, _) = run(&mut strict);
assert_eq!(a, b, "{p}");
let old = ["bodies", "changed_bodies", "created_bodies", "feature", "ms", "name", "removed_bodies", "warning"];
assert!(old.iter().all(|k| a_keys.contains(*k)), "{a}");
assert!(a_keys.iter().all(|k| old.contains(&k.as_str()) || matches!(k.as_str(), "component" | "notice")), "{a}");
}
}
/// What the handler ignores or reads as left out, strict refuses; off and warn run it as before.
#[test]
fn revolve_wrong_typed_keys_are_reported() {
for (key, value) in [
("direction", json!("negative")),
("direction", json!(true)),
("symmetric", json!("yes")),
("exact_regions", json!(1)),
("angle", json!(true)),
("angle", json!(" ")),
("angle2", json!([])),
("face", json!("x")),
("face", json!([1, 2])),
("face_body", json!(5)),
("body", json!(5)),
("name", json!(5)),
("body_name", json!(5)),
("operation", json!(5)),
("colour", json!("red")),
] {
let p = json!({"axis": "y", "angle": 30, key: value});
let mut off = revolve_rig(ParamCheck::Off);
let r = off.execute("solid.revolve", &p);
assert!(r.is_ok(), "{p}: {r:?}");
let mut warn = revolve_rig(ParamCheck::Warn);
let r = warn.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{p}: {r}");
let mut strict = revolve_rig(ParamCheck::Strict);
assert!(strict.execute("solid.revolve", &p).is_err(), "{p}");
}
}
+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` and
/// `file.save_mesh` also StlAscii or ThreeMfTagged, and for `file.export` also 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);
}
+362
View File
@@ -0,0 +1,362 @@
//! Contract and reply type for `file.insert_mesh` in `cmd/file.rs` (#13309).
//!
//! 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. Whether the file can be read stays the
//! handler's to check. A key the handler refuses when it is left out (`path`) is optional in the
//! type and required in the schema, so the handler's message, not the check's "is required", is
//! what a caller sees.
//!
//! Some keys are read only in some cases, and the contract reads them in the same cases:
//!
//! - nothing but `path` is read when the file is not a mesh file (the handler refuses it first);
//! - `up` is read only for a glTF file (any other file is refused with the handler's own message,
//! whatever `up` says, so that message is left to the handler);
//! - `names` and `folder` are read only when `up` is given for a glTF file: the handler checks
//! them there, as it does for a STEP file, and they change nothing for a mesh;
//! - `units` is read whatever the file (a glTF or 3MF file is refused it afterwards, which the
//! handler says).
//!
//! A value of the wrong type that the handler reads as if the key were left out (a `name` that is
//! not text) is refused by the contract, as schemas.md has it: that difference is what warn and
//! strict report. 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), except that a bad
//! `path` is always the first.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use super::check::{located, whole};
use super::types::{not_blank_pattern, whitespace_class};
/// What the handler says of a `path` that is missing, not text, blank or too long.
const PATH_REQUIRED: &str = "`path` is required";
const UP_MESSAGE: &str = "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)";
const NAMES_MESSAGE: &str = "`names` must be \"auto\", \"solid\", \"product\" or \"instance\"";
const FOLDER_MESSAGE: &str =
"`folder` must be true (an import that brings more than one item goes into a `Group #N` history folder) or false (no folder)";
const ORIGIN_MESSAGE: &str =
"`origin` must be true (the mesh is moved so its box is centred on X and Y with its base on Z = 0) or false (the file's coordinates are kept)";
/// A key that is absent or `null` (the handler reads both as left out), else the value read by
/// `read`, with the handler's `message` when `read` refuses it.
fn read_key<'de, D: Deserializer<'de>, T>(d: D, message: &str, read: impl FnOnce(&Value) -> Option<T>) -> Result<Option<T>, D::Error> {
let value = Value::deserialize(d)?;
if value.is_null() {
return Ok(None);
}
read(&value).map(Some).ok_or_else(|| D::Error::custom(whole(message)))
}
fn path_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(Some(s)),
_ => Err(D::Error::custom(whole(PATH_REQUIRED))),
}
}
fn up_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<UpAxis>, D::Error> {
read_key(d, UP_MESSAGE, |v| match v.as_str().map(|u| u.trim().to_ascii_lowercase()).as_deref() {
Some("z") => Some(UpAxis::Z),
Some("y") => Some(UpAxis::Y),
_ => None,
})
}
fn names_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<StepNamesParam>, D::Error> {
read_key(d, NAMES_MESSAGE, |v| {
let word = v.as_str().and_then(solidcraft_io::StepNames::parse)?;
Some(match word {
solidcraft_io::StepNames::Auto => StepNamesParam::Auto,
solidcraft_io::StepNames::Solid => StepNamesParam::Solid,
solidcraft_io::StepNames::Product => StepNamesParam::Product,
solidcraft_io::StepNames::Instance => StepNamesParam::Instance,
})
})
}
fn units_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<MeshUnits>, D::Error> {
let value = Value::deserialize(d)?;
match &value {
Value::Null => Ok(None),
Value::String(u) => match solidcraft_doc::point_cloud::UNITS.iter().find(|(name, _)| name.eq_ignore_ascii_case(u)) {
Some((name, _)) => Ok(Some(MeshUnits((*name).to_string()))),
None => Err(D::Error::custom(whole(format!("`units` must be one of {} (not `{u}`)", unit_names())))),
},
_ => Err(D::Error::custom(whole(format!("`units` must be one of {}", unit_names())))),
}
}
fn flag_key<'de, D: Deserializer<'de>>(d: D, message: &str) -> Result<Option<bool>, D::Error> {
read_key(d, message, Value::as_bool)
}
fn folder_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag_key(d, FOLDER_MESSAGE)
}
fn origin_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag_key(d, ORIGIN_MESSAGE)
}
/// The unit names, as the handler lists them in its messages.
fn unit_names() -> String {
solidcraft_doc::point_cloud::UNITS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
}
/// The keys `file.insert_mesh` reads. See [`InsertMeshParams`], which leaves out the keys the
/// handler does not read for the call.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = path_required)]
pub struct InsertMeshKeys {
// Optional in the type and required in the schema (`path_required`): left out, the handler
// refuses it with its own message.
/// The file to insert: `.3mf`, `.stl`, `.obj`, `.ply` (with faces), `.glb` or `.gltf` (any
/// case), not blank and fewer than 4096 UTF-8 bytes. Any other kind of file is refused. Its
/// meshes join the design as mesh bodies in a new component, placed in the active component.
#[serde(default, deserialize_with = "path_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub path: Option<String>,
/// The new component's name, and the feature's. Default (also when blank): the file's name
/// without its extension.
#[serde(default)]
pub name: Option<String>,
/// For a glTF file only (any other file is refused it): `y` (the default: glTF has Y up)
/// turns the import's component a quarter turn about X, so Y becomes Z, and the reply gives
/// its occurrence as `stand_up`; `z` keeps the file's axes. Any case.
#[serde(default, deserialize_with = "up_key", skip_serializing_if = "Option::is_none")]
pub up: Option<UpAxis>,
/// Read only when `up` is given for a glTF file, where it is checked and changes nothing:
/// `auto`, `solid`, `product` or `instance`, in any case. It is how a STEP file's bodies are
/// named (`file.insert_step`).
#[serde(default, deserialize_with = "names_key", skip_serializing_if = "Option::is_none")]
pub names: Option<StepNamesParam>,
/// Read only when `up` is given for a glTF file, where it is checked and changes nothing:
/// true or false. It is whether a STEP import goes into a history folder (`file.insert_step`).
#[serde(default, deserialize_with = "folder_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub folder: Option<bool>,
/// For an STL, OBJ or PLY file: the unit its coordinates are in, scaled to millimetres as the
/// file is read: `mm`, `cm`, `m`, `in` or `ft`, in any case. Default: mm. A 3MF or glTF file
/// states its own, and is refused `units`. The design keeps the millimetre mesh and not the
/// unit, so insert the file again to try another.
#[serde(default, deserialize_with = "units_key", skip_serializing_if = "Option::is_none")]
pub units: Option<MeshUnits>,
/// Whether, once the mesh is read, scaled and stood up, the import's occurrence is moved so
/// the box around its bodies is centred on X and Y of the active component with its lowest
/// point on Z = 0 (the reply gives the shift as `moved_to_origin`). Default: false, so the
/// mesh keeps the coordinates its file gives it.
#[serde(default, deserialize_with = "origin_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub origin: Option<bool>,
}
/// Leaves out the keys the handler does not read for this call, in the order it looks at them.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let Some(path) = keys.get("path").and_then(Value::as_str).filter(|p| !p.trim().is_empty() && p.len() < 4096).map(str::to_string) else {
return;
};
if !solidcraft_io::is_mesh_path(&path) && !solidcraft_io::is_ply_path(&path) {
// Refused for its kind before anything else is read.
for key in ["name", "up", "names", "folder", "units", "origin"] {
keys.remove(key);
}
return;
}
let up_given = keys.get("up").is_some_and(|u| !u.is_null());
if !(up_given && solidcraft_io::is_gltf_path(&path)) {
keys.remove("names");
keys.remove("folder");
}
if !solidcraft_io::is_gltf_path(&path) {
keys.remove("up");
}
}
/// Insert a 3MF, STL, OBJ, PLY or glTF mesh file into the design as mesh bodies, in a new
/// component named after the file and placed in the active component. A glTF file's node tree
/// becomes nested components held as one rigid group until `component.unlock`, a mesh placed
/// again becomes another occurrence, its materials become the bodies' appearances, and its
/// metres become millimetres.
#[derive(Debug)]
pub struct InsertMeshParams(pub InsertMeshKeys);
impl<'de> Deserialize<'de> for InsertMeshParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
// A bad `path` is the handler's first refusal, whatever else is wrong.
if let Some(path) = value.as_object().map(|o| o.get("path")) {
let ok = path.and_then(Value::as_str).is_some_and(|p| !p.trim().is_empty() && p.len() < 4096);
if !ok {
let only = serde_json::json!({"path": path.cloned().unwrap_or(Value::Null)});
return serde_path_to_error::deserialize::<_, InsertMeshKeys>(&only).map(InsertMeshParams).map_err(|e| D::Error::custom(located(&e)));
}
}
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map(InsertMeshParams).map_err(|e| D::Error::custom(located(&e)))
}
}
impl JsonSchema for InsertMeshParams {
fn schema_name() -> Cow<'static, str> {
"InsertMeshParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertMeshKeys::json_schema(generator);
schema.insert("description".into(), INSERT_MESH.into());
schema
}
}
const INSERT_MESH: &str = "Insert a 3MF, STL, OBJ, PLY or glTF mesh file into the design as mesh bodies, in a new component named after the file and placed in the active component. A glTF file's node tree becomes nested components held as one rigid group until `component.unlock`, a mesh placed again becomes another occurrence, its materials become the bodies' appearances, and its metres become millimetres.";
/// `path` is required, as the handler has it (not blank, fewer than 4096 bytes; `maxLength`
/// counts characters, so 4095 is as close as the schema gets). `names` and `folder` have their
/// shapes only where the handler reads them: when `up` is given (not null) for a glTF file.
fn path_required(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
props.insert(
"path".into(),
serde_json::json!({
"description": props.get("path").and_then(|p| p.get("description")).cloned().unwrap_or_default(),
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern(),
}),
);
let mut read_only_with_up = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let names = read_only_with_up("names");
let folder = read_only_with_up("folder");
schema.insert("anyOf".into(), serde_json::json!([{"properties": {"up": {"type": "null"}}}, {"properties": {"names": names, "folder": folder}}]));
}
/// Which way is up in a glTF file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpAxis {
/// The file's axes are kept.
Z,
/// The file has Y up: its Y becomes Z.
Y,
}
/// Case-insensitive words (`word`), each letter in both cases, as a pattern; the pattern allows
/// the whitespace Rust's `trim` removes around them when `padded`.
fn words_pattern(words: &[&str], padded: bool) -> String {
let word = |w: &str| w.chars().map(|c| format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase())).collect::<String>();
let alternatives = words.iter().map(|w| word(w)).collect::<Vec<_>>().join("|");
if padded {
let ws = whitespace_class();
format!("^[{ws}]*({alternatives})[{ws}]*$")
} else {
format!("^({alternatives})$")
}
}
impl JsonSchema for UpAxis {
fn schema_name() -> Cow<'static, str> {
"UpAxis".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "`z` (the file's axes are kept) or `y` (the file has Y up: its Y becomes Z), in any case.",
"type": "string",
"pattern": words_pattern(&["z", "y"], true)
})
}
}
/// How a STEP file's bodies are named; read, and checked, for a glTF file with `up` too.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StepNamesParam {
Auto,
Solid,
Product,
Instance,
}
impl JsonSchema for StepNamesParam {
fn schema_name() -> Cow<'static, str> {
"StepNamesParam".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "`auto`, `solid`, `product` or `instance`, in any case.",
"type": "string",
"pattern": words_pattern(&["auto", "solid", "product", "instance"], true)
})
}
}
/// A length unit of an STL, OBJ or PLY file: one of `solidcraft_doc::point_cloud::UNITS`, in the
/// case the table spells it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MeshUnits(pub String);
impl JsonSchema for MeshUnits {
fn schema_name() -> Cow<'static, str> {
"MeshUnits".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let names: Vec<&str> = solidcraft_doc::point_cloud::UNITS.iter().map(|(n, _)| *n).collect();
json_schema!({
"description": "`mm`, `cm`, `m`, `in` or `ft`, in any case.",
"type": "string",
"pattern": words_pattern(&names, false)
})
}
}
/// What `file.insert_mesh` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MeshInserted {
/// The file, as given.
pub path: String,
/// The mesh import feature's id.
pub feature: u64,
/// The feature's name.
pub name: String,
/// The bodies the import made, by name.
pub bodies: Vec<String>,
/// The same names as `bodies`.
pub created_bodies: Vec<String>,
/// What the file reader noticed and put right.
pub warnings: Vec<String>,
/// The new component's id.
pub component: u64,
/// The new component's name.
pub component_name: String,
/// The component's occurrence in the active component; null when it has none.
pub occurrence: Option<u64>,
/// How many components were made below it (a glTF file's nodes). Only when there are some.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subcomponents: Option<usize>,
/// How many rigid joints hold its contents together. Only when `subcomponents` is given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rigid_joints: Option<usize>,
/// With `origin`: the shift, [x, y, z] in mm, that moved the import to the origin; absent
/// when it was not moved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moved_to_origin: Option<[f64; 3]>,
/// The unit the file's coordinates were read in, when `units` was given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub units: Option<String>,
/// For a glTF file stood up from Y up to Z up: the occurrence that was turned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stand_up: Option<u64>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "insert_mesh_tests.rs"]
mod tests;
@@ -0,0 +1,290 @@
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::{InsertMeshParams, MeshInserted, StepNamesParam, UpAxis};
use crate::Session;
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() {
assert_keys(input_schema_for::<InsertMeshParams>(), &["folder", "name", "names", "origin", "path", "units", "up"]);
}
#[test]
fn strict_keeps_todays_errors() {
let long = "x".repeat(4096);
assert_strict_keeps_todays_errors(&[
("file.insert_mesh", json!({})),
("file.insert_mesh", json!({"path": ""})),
("file.insert_mesh", json!({"path": " "})),
("file.insert_mesh", json!({"path": 5})),
("file.insert_mesh", json!({"path": null})),
("file.insert_mesh", json!({"path": long})),
("file.insert_mesh", json!({"up": "q", "units": "yd"})),
// The kind of file is refused before anything else is read.
("file.insert_mesh", json!({"path": "x.step"})),
("file.insert_mesh", json!({"path": "x.step", "up": "q", "units": 5, "origin": 1})),
("file.insert_mesh", json!({"path": "x.step", "name": 5})),
// `up` is for a glTF file only, whatever it says.
("file.insert_mesh", json!({"path": "x.stl", "up": "y"})),
("file.insert_mesh", json!({"path": "x.stl", "up": "q"})),
("file.insert_mesh", json!({"path": "x.obj", "up": 5})),
("file.insert_mesh", json!({"path": "x.glb", "up": "q"})),
("file.insert_mesh", json!({"path": "x.glb", "up": 5})),
("file.insert_mesh", json!({"path": "x.glb", "up": "y", "names": "bogus"})),
("file.insert_mesh", json!({"path": "x.gltf", "up": "z", "names": 5})),
("file.insert_mesh", json!({"path": "x.gltf", "up": "z", "folder": "yes"})),
("file.insert_mesh", json!({"path": "x.stl", "units": "yd"})),
("file.insert_mesh", json!({"path": "x.stl", "units": 5})),
("file.insert_mesh", json!({"path": "x.stl", "units": true})),
("file.insert_mesh", json!({"path": "x.3mf", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.glb", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.stl", "origin": "yes"})),
("file.insert_mesh", json!({"path": "x.stl", "origin": 1})),
// The kind of file is read in any case, and a PLY file is a mesh too.
("file.insert_mesh", json!({"path": "X.GLB", "up": "q"})),
("file.insert_mesh", json!({"path": "X.STL", "up": "y"})),
("file.insert_mesh", json!({"path": "X.3MF", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.ply", "units": "yd"})),
("file.insert_mesh", json!({"path": "x.ply", "up": "y"})),
// A file that isn't there.
("file.insert_mesh", json!({"path": "does-not-exist.stl"})),
("file.insert_mesh", json!({"path": "does-not-exist.glb", "up": "Z", "names": " Auto ", "folder": false})),
]);
}
fn parse(p: Value) -> Result<InsertMeshParams, String> {
serde_json::from_value(p).map_err(|e| e.to_string())
}
/// The keys read as the handler reads them: the same words in any case, `null` as left out, and
/// the keys it doesn't read in a call left unread, so a bad one there is no problem.
#[test]
fn the_types_read_each_key_as_the_handler_does() {
let keys = |p: Value| parse(p).unwrap_or_else(|e| panic!("{e}")).0;
let k = keys(json!({"path": "a.glb", "name": "Mine", "up": " Y ", "names": " Solid ", "folder": true, "units": "IN", "origin": false}));
assert_eq!(k.path.as_deref(), Some("a.glb"));
assert_eq!(k.name.as_deref(), Some("Mine"));
assert_eq!(k.up, Some(UpAxis::Y));
assert_eq!(k.names, Some(StepNamesParam::Solid));
assert_eq!(k.folder, Some(true));
assert_eq!(k.units.map(|u| u.0), Some("in".to_string()));
assert_eq!(k.origin, Some(false));
assert_eq!(keys(json!({"path": "a.glb", "up": "Z"})).up, Some(UpAxis::Z));
let none = keys(json!({"path": "a.stl", "name": null, "up": null, "names": null, "folder": null, "units": null, "origin": null}));
assert!(none.name.is_none() && none.up.is_none() && none.units.is_none() && none.origin.is_none());
// Not read: `names` and `folder` without an `up` for a glTF file, `up` for any other file,
// everything but `path` for a file that isn't a mesh.
for p in [
json!({"path": "a.stl", "names": 5, "folder": "x"}),
json!({"path": "a.glb", "names": 5, "folder": "x"}),
json!({"path": "a.glb", "up": null, "names": 5, "folder": "x"}),
json!({"path": "a.stl", "up": "sideways", "names": 5}),
json!({"path": "a.step", "up": 1, "names": 5, "folder": 5, "units": 5, "origin": 5}),
] {
parse(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
}
parse(json!({"path": "a.GLB", "up": "y", "names": "auto"})).unwrap_or_else(|e| panic!("{e}"));
assert!(parse(json!({"path": "a.GLB", "up": "y", "names": "x"})).is_err());
parse(json!({"path": "a.PLY", "up": 5, "names": 5})).unwrap_or_else(|e| panic!("{e}"));
let k = keys(json!({"path": "a.glb", "names": 5}));
assert!(k.names.is_none() && k.folder.is_none());
// Read, and refused with the handler's message.
let refused = |p: Value| crate::schema::issues("file.insert_mesh", &p).into_iter().next().map(|i| i.message).unwrap_or_default();
for (p, message) in [
(json!({"path": "a.glb", "up": "x"}), "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)"),
(json!({"path": "a.glb", "up": "y", "names": "x"}), "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""),
(json!({"path": "a.stl", "units": "yd"}), "`units` must be one of mm, cm, m, in, ft (not `yd`)"),
(json!({"path": "a.stl", "units": 1}), "`units` must be one of mm, cm, m, in, ft"),
(json!({"path": "a.3mf", "units": []}), "`units` must be one of mm, cm, m, in, ft"),
(json!({"path": "a.stl", "origin": "yes"}), "`origin` must be true"),
(json!({"path": "a.glb", "up": "z", "folder": 0}), "`folder` must be true"),
(json!({}), "`path` is required"),
(json!({"path": " "}), "`path` is required"),
(json!({"up": "x", "path": 5}), "`path` is required"),
] {
let e = refused(p.clone());
assert!(e.starts_with(message), "{p}: {e}");
}
}
/// The check names what is wrong, in the handler's words, and a key the handler would ignore is
/// still a known key.
#[test]
fn the_check_names_what_is_wrong() {
let issues = |p: Value| crate::schema::issues("file.insert_mesh", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert_eq!(issues(json!({"path": "a.stl", "unit": "in"})), ["unknown key `unit`; did you mean `units`?"]);
assert_eq!(issues(json!({"path": "a.stl", "units": "yd"})), ["`units` must be one of mm, cm, m, in, ft (not `yd`)"]);
assert_eq!(issues(json!({"path": "a.stl", "name": 5})), ["`name` must be text"]);
assert_eq!(issues(json!({})), ["`path` is required"]);
// `name` is read as text or not at all; a number is ignored by the handler but is a mistake.
assert_eq!(issues(json!({"path": "a.stl", "name": null})), Vec::<String>::new());
assert!(issues(json!({"path": "a.stl", "up": "y", "names": 5})).is_empty(), "an `up` for another file is the handler's to refuse");
assert!(issues(json!({"path": "a.stl", "names": 5, "folder": 5})).is_empty());
}
/// The published schema: `path` is required and shaped as the handler reads it; `names` and
/// `folder` have their shapes only where they are read.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<InsertMeshParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.glb", "up": "Y", "names": "Auto", "folder": true, "name": "N"}), true),
(json!({"path": "a.stl", "units": "in", "origin": true}), true),
(json!({"path": "a.stl", "units": null, "origin": null, "up": null}), true),
// Left unread, so not shaped.
(json!({"path": "a.stl", "names": 5, "folder": "x"}), true),
(json!({"path": "a.glb", "up": null, "names": 5, "folder": "x"}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a.stl", "origin": "yes"}), false),
(json!({"path": "a.stl", "units": 5}), false),
(json!({"path": "a.stl", "folder": "x", "up": "y"}), false),
(json!({"path": "a.glb", "up": "y", "names": 5}), false),
(json!({"path": "a.stl", "name": 5}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// `conforms` doesn't read patterns: pin the text of the ones the schema carries.
#[test]
fn the_patterns_are_pinned() {
let schema = input_schema_for::<InsertMeshParams>();
let schema = schema.as_value();
assert_eq!(schema["properties"]["path"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(schema["properties"]["path"]["maxLength"], json!(4095));
let ws = crate::schema::types::whitespace_class();
assert_eq!(schema["$defs"]["UpAxis"]["pattern"], json!(format!("^[{ws}]*([zZ]|[yY])[{ws}]*$")));
assert_eq!(schema["$defs"]["MeshUnits"]["pattern"], json!("^([mM][mM]|[cC][mM]|[mM]|[iI][nN]|[fF][tT])$"));
assert_eq!(
schema["$defs"]["StepNamesParam"]["pattern"],
json!(format!("^[{ws}]*([aA][uU][tT][oO]|[sS][oO][lL][iI][dD]|[pP][rR][oO][dD][uU][cC][tT]|[iI][nN][sS][tT][aA][nN][cC][eE])[{ws}]*$"))
);
}
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-insert-mesh-schema-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
/// A cube of side 10 mm, saved as STL, and the same as 3MF.
fn cube_files(dir: &Path) -> (String, String) {
let mut part = Session::default();
let (stl, threemf) = (dir.join("cube.stl").to_string_lossy().to_string(), dir.join("cube.3mf").to_string_lossy().to_string());
for (id, p) in [
("solid.box", json!({"length": 10, "width": 10, "height": 10})),
("file.save_mesh", json!({"path": stl})),
("file.export_3mf", json!({"path": threemf})),
] {
part.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
(stl, threemf)
}
/// A glTF file (a glb): a cube of side 0.01 m placed by a `Base` node with an `Arm` node below
/// it, so the import has components of its own.
fn glb(dir: &Path) -> String {
let s = 0.01f32;
let p: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32 * s, ((i >> 1) & 1) as f32 * s, ((i >> 2) & 1) as f32 * s]).collect();
let t: [u16; 36] = [0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7, 0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5];
let mut bin: Vec<u8> = p.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
bin.extend(t.iter().flat_map(|v| v.to_le_bytes()));
let json = json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [
{"name": "Base", "mesh": 0, "children": [1]},
{"name": "Arm", "mesh": 1, "translation": [0.0, 0.05, 0.0]},
],
"meshes": [
{"name": "Block", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
{"name": "Peg", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [s, s, s]},
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"},
],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
"buffers": [{"byteLength": bin.len()}],
});
let path = dir.join("robot.glb");
std::fs::write(&path, solidcraft_io::glb_container(json.to_string().as_bytes(), &bin)).unwrap_or_else(|e| panic!("{e}"));
path.to_string_lossy().to_string()
}
fn matches(reply: &Value) {
let schema = output_schema_for::<MeshInserted>();
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
}
/// Every form the command takes, run in Warn: each reply matches its output schema and carries
/// no warning (Warn runs a call the contract refuses anyway, so this shows the contract takes
/// each call). The keys the handler leaves unread are given bad values on purpose.
#[test]
fn replies_match_the_output_schema() {
let dir = folder("replies");
let (stl, threemf) = cube_files(&dir);
let robot = glb(&dir);
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |p: Value| {
let r = s.execute("file.insert_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
r
};
let plain = run(json!({"path": stl}));
assert_eq!(plain["component_name"], "cube", "{plain}");
assert!(plain.get("units").is_none() && plain.get("moved_to_origin").is_none() && plain.get("stand_up").is_none(), "{plain}");
assert!(plain.get("subcomponents").is_none() && plain["bodies"] == plain["created_bodies"], "{plain}");
assert!(plain["occurrence"].is_u64(), "{plain}");
let named = run(json!({"path": stl, "name": "Block", "units": "IN", "origin": true}));
assert_eq!(named["units"], "in", "{named}");
assert_eq!(named["component_name"], "Block", "{named}");
assert!(named["moved_to_origin"].as_array().is_some_and(|a| a.len() == 3), "{named}");
run(json!({"path": stl, "units": "cm", "origin": false, "up": null, "name": null}));
// Keys left unread, with values that would be refused if they were read.
run(json!({"path": stl, "names": 5, "folder": "x"}));
run(json!({"path": threemf, "names": [], "folder": {}}));
run(json!({"path": robot, "names": 5, "folder": "x"}));
let stood = run(json!({"path": robot}));
assert!(stood["stand_up"].is_u64() && stood["subcomponents"].is_u64() && stood["rigid_joints"].is_u64(), "{stood}");
let kept = run(json!({"path": robot, "up": "Z", "names": " Instance ", "folder": true, "origin": true}));
assert!(kept.get("stand_up").is_none() && kept["subcomponents"].is_u64(), "{kept}");
let turned = run(json!({"path": robot, "up": " y "}));
assert!(turned["stand_up"].is_u64(), "{turned}");
// A misspelt key still runs in Warn, says so, and the reply still matches.
let warned = s.execute("file.insert_mesh", &json!({"path": stl, "unit": "in"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `unit`; did you mean `units`?"]), "{warned}");
matches(&warned);
let _ = std::fs::remove_dir_all(&dir);
}
/// In Strict a call that works today is not refused, and one that fails fails with the
/// handler's message (see `strict_keeps_todays_errors`).
#[test]
fn strict_runs_the_calls_that_work() {
let dir = folder("strict");
let (stl, _) = cube_files(&dir);
let robot = glb(&dir);
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
for p in [json!({"path": stl, "units": "mm"}), json!({"path": robot, "up": "y", "names": "auto", "folder": false})] {
s.execute("file.insert_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
}
let e = s.execute("file.insert_mesh", &json!({"path": stl, "unit": "in"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("unknown key `unit`"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
+5
View File
@@ -54,6 +54,7 @@ mod coverage_tests;
mod tests;
// The contracts, by command file.
pub mod align;
pub mod appearance;
pub mod bom;
pub mod browser;
@@ -71,6 +72,7 @@ pub mod features;
pub mod features_more;
pub mod file;
pub mod frame;
pub mod insert_mesh;
pub mod inspect;
pub mod interference;
pub mod joint_sweep;
@@ -105,6 +107,7 @@ pub mod render_stage;
pub mod render_surface_resources;
pub mod render_ui;
pub mod save;
pub mod save_mesh;
pub mod section;
pub mod select;
pub mod sheet;
@@ -115,6 +118,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 +127,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;
+364
View File
@@ -0,0 +1,364 @@
//! Contracts and result types for `file.save_mesh` in `cmd/file.rs`.
//!
//! 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. Whether a body exists, a slicer is installed or the file
//! can be written stays 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).
//!
//! `path` is the one key the handler refuses when it is left out: it is optional in the type, so the
//! handler keeps its message, and required in the schema.
//!
//! Two keys are read only in some cases, and the contract reads them in the same cases: `ascii`
//! only when `format` is `stl` (in any case), and `single_instance` only when `slicer` is text. A
//! value of the wrong type that the handler reads as if the key were left out (`ascii: "yes"` next
//! to `format: "stl"`, a number for `slicer`) is refused by the contract, as schemas.md has it: that
//! difference is what warn and strict report.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use super::check::{located, whole};
use super::types::{not_blank_pattern, whitespace_class};
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
T::deserialize(d).map(Some)
}
/// The keys `file.save_mesh` reads. See [`SaveMeshParams`], which leaves `ascii` and
/// `single_instance` unread in the cases the handler does.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = conditional_keys)]
pub struct SaveMeshKeys {
/// The file to write: not blank, fewer than 4096 UTF-8 bytes.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "MeshPath")]
pub path: Option<MeshPath>,
/// The file format, in any case: `stl`, `3mf`, or `3mf-tagged` (3MF with a face tag on each
/// triangle, docs/formats/3mf-face-tags.md). Default: from the extension of `path` (`.3mf`
/// gives 3mf), else stl.
#[serde(default)]
pub format: Option<MeshFormat>,
/// The bodies to save, by name. Default: every body.
#[serde(default)]
pub bodies: Option<MeshBodies>,
/// STL only (read only when `format` is `stl`): true writes ASCII STL instead of binary.
/// Default: false.
#[serde(default)]
pub ascii: Option<bool>,
/// A slicer to send the saved file to, after it is written where `path` says, through the
/// same steps as `file.send_to_slicer`: an id or a name from `file.slicers`, the program path
/// it lists, or a program the user added under Custom…. A slicer that has to be started first
/// is waited for. Kept for scripts; may be deprecated later in favour of `file.send_to_slicer`
/// or a Send to App workflow. Default: none.
#[serde(default)]
pub slicer: Option<String>,
/// Read only when `slicer` is given: true opens the file in the window the slicer already has
/// open, for slicers that load files that way (see `file.send_to_slicer`). Default: true.
#[serde(default)]
pub single_instance: Option<bool>,
/// How closely the mesh follows curved faces (STL, OBJ and 3MF), in any case: `standard`, `fine`
/// or `finest`, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size. Default: standard.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// 3MF only: `body` is one object per body; `component` is 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 (other bodies stay objects of their own). `component` is refused for
/// other formats. Default: body.
#[serde(default)]
pub objects: Option<MeshObjects>,
}
/// Leaves out the keys the handler never reads for this call: `ascii` unless `format` is `stl`,
/// and `single_instance` unless `slicer` is text.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let stl = keys.get("format").and_then(Value::as_str).is_some_and(|f| f.eq_ignore_ascii_case("stl"));
if !stl {
keys.remove("ascii");
}
if !keys.get("slicer").is_some_and(Value::is_string) {
keys.remove("single_instance");
}
}
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<SaveMeshKeys, D::Error> {
let mut value = Value::deserialize(d)?;
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
}
/// Save bodies as a mesh file: binary or ASCII STL, 3MF, or 3MF with a face tag on each triangle.
/// The format is `format`, else the extension of `path`, else STL. With `slicer`, the saved file
/// is then sent to that program. Nothing is added to undo.
#[derive(Debug)]
pub struct SaveMeshParams(pub SaveMeshKeys);
impl<'de> Deserialize<'de> for SaveMeshParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(SaveMeshParams)
}
}
impl JsonSchema for SaveMeshParams {
fn schema_name() -> Cow<'static, str> {
"SaveMeshParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = SaveMeshKeys::json_schema(generator);
schema.insert("description".into(), SAVE_MESH.into());
schema
}
}
const SAVE_MESH: &str = "Save bodies as a mesh file: binary or ASCII STL, 3MF, or 3MF with a face tag on each triangle. The format is `format`, else the extension of `path`, else STL. With `slicer`, the saved file is then sent to that program. Nothing is added to undo.";
/// The pattern for `format`: the three words, in any case.
const FORMAT_PATTERN: &str = "^([Ss][Tt][Ll]|3[Mm][Ff]|3[Mm][Ff]-[Tt][Aa][Gg][Gg][Ee][Dd])$";
/// The pattern for `quality`: the three words, in any case, with whitespace around them allowed
/// (the handler trims).
fn quality_pattern() -> String {
let ws = whitespace_class();
format!("^[{ws}]*([Ss][Tt][Aa][Nn][Dd][Aa][Rr][Dd]|[Ff][Ii][Nn][Ee]|[Ff][Ii][Nn][Ee][Ss][Tt])[{ws}]*$")
}
/// `path` is required (the handler says so when it is left out); `ascii` and `single_instance`
/// have their shapes only where the handler reads them, as [`leave_unread`] has it.
fn conditional_keys(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
let mut read_only_when = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let ascii = read_only_when("ascii");
let single_instance = read_only_when("single_instance");
// `format` is `stl` in any case: the eight spellings, which `enum` says and a pattern would only approximate.
let spellings: Vec<String> =
(0..8).map(|n| "stl".chars().enumerate().map(|(i, c)| if n >> i & 1 == 1 { c.to_ascii_uppercase() } else { c }).collect()).collect();
let stl = json!({"required": ["format"], "properties": {"format": {"enum": spellings}}});
let slicer = json!({"required": ["slicer"], "properties": {"slicer": {"type": "string"}}});
schema.insert(
"allOf".into(),
json!([
{"anyOf": [{"not": stl}, {"properties": {"ascii": ascii}}]},
{"anyOf": [{"not": slicer}, {"properties": {"single_instance": single_instance}}]},
]),
);
}
/// The file to write, as `path_arg` reads `path`: not blank and under 4096 bytes, or the
/// handler's "`path` is required" (anything but text reads as no `path`).
#[derive(Clone, Debug, PartialEq)]
pub struct MeshPath(pub String);
impl<'de> Deserialize<'de> for MeshPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(MeshPath(s)),
_ => Err(D::Error::custom(whole("`path` is required"))),
}
}
}
impl JsonSchema for MeshPath {
fn schema_name() -> Cow<'static, str> {
"MeshPath".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
// `maxLength` counts characters, so 4095 is as close as the schema gets to "under 4096 bytes".
json_schema!({
"description": "The file to write: not blank, fewer than 4096 UTF-8 bytes.",
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern()
})
}
}
/// The bodies to save, read by the handler's own `string_list`: a name or a list of names, with its
/// messages for anything else.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshBodies(pub Vec<String>);
impl<'de> Deserialize<'de> for MeshBodies {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let keys = json!({"bodies": Value::deserialize(d)?});
match crate::params::string_list("file.save_mesh", &keys, "bodies") {
Ok(names) => Ok(MeshBodies(names)),
Err(crate::EngineError::BadParams { msg, .. }) => Err(D::Error::custom(whole(msg))),
Err(e) => Err(D::Error::custom(whole(e))),
}
}
}
impl JsonSchema for MeshBodies {
fn schema_name() -> Cow<'static, str> {
"MeshBodies".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A body name, or a list of body names (at most 10000).",
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": crate::params::MAX_STRING_LIST}]
})
}
}
/// A mesh format, as `file.save_mesh` reads `format` (the words in any case).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshFormat {
/// STL, binary unless `ascii` is true.
Stl,
/// 3MF.
ThreeMf,
/// 3MF with a face tag on each triangle.
ThreeMfTagged,
}
impl<'de> Deserialize<'de> for MeshFormat {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match String::deserialize(d)?.to_ascii_lowercase().as_str() {
"stl" => Ok(MeshFormat::Stl),
"3mf" => Ok(MeshFormat::ThreeMf),
"3mf-tagged" => Ok(MeshFormat::ThreeMfTagged),
other => Err(D::Error::custom(whole(format!("unknown mesh format `{other}` (stl, 3mf, 3mf-tagged)")))),
}
}
}
impl JsonSchema for MeshFormat {
fn schema_name() -> Cow<'static, str> {
"MeshFormat".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "stl, 3mf or 3mf-tagged, in any case.",
"type": "string",
"pattern": FORMAT_PATTERN
})
}
}
/// How closely the mesh follows curved faces, as `quality` reads it (the words in any case,
/// whitespace around them ignored).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshQualityParam {
/// A chord tolerance of 2e-4 of each body's size.
Standard,
/// 5e-5 of each body's size.
Fine,
/// 1e-5 of each body's size.
Finest,
}
impl<'de> Deserialize<'de> for MeshQualityParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let named = v.as_str().filter(|s| solidcraft_io::MeshQuality::from_name(s).is_some()).map(|s| s.trim().to_ascii_lowercase());
match named.as_deref() {
Some("standard") => Ok(MeshQualityParam::Standard),
Some("fine") => Ok(MeshQualityParam::Fine),
Some("finest") => Ok(MeshQualityParam::Finest),
_ => Err(D::Error::custom(whole(format!("`quality` must be standard, fine or finest, not {v}")))),
}
}
}
impl JsonSchema for MeshQualityParam {
fn schema_name() -> Cow<'static, str> {
"MeshQualityParam".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "standard (a chord tolerance of 2e-4 of each body's size), fine (5e-5) or finest (1e-5), in any case.",
"type": "string",
"pattern": quality_pattern()
})
}
}
/// What each 3MF object holds, as `objects` reads it (lower case only).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshObjects {
/// One object per body.
Body,
/// One object for each placed component with two or more bodies.
Component,
}
impl<'de> Deserialize<'de> for MeshObjects {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match v.as_str() {
Some("body") => Ok(MeshObjects::Body),
Some("component") => Ok(MeshObjects::Component),
_ => Err(D::Error::custom(whole(format!("`objects` must be body or component, not {v}")))),
}
}
}
impl JsonSchema for MeshObjects {
fn schema_name() -> Cow<'static, str> {
"MeshObjects".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "body (one object per body) or component (one object for each placed component with two or more bodies, with those bodies as its parts). Lower case only.",
"type": "string",
"enum": ["body", "component"]
})
}
}
/// What happened when the saved file was handed to `slicer`. Only the one that applies is there.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct SlicerDelivery {
/// The slicer's name: the file was opened there.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer: Option<String>,
/// Why the program would not open the file. The file is saved either way.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer_error: Option<String>,
/// The slicer's name: the slicer had to be started first and was not ready (never for
/// `file.save_mesh`, which waits for it).
#[serde(skip_serializing_if = "Option::is_none")]
pub starting: Option<String>,
/// The slicer's name: it was started but was not ready within 10 seconds, so the file was
/// not sent. The file is saved.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer_timeout: Option<String>,
}
/// What `file.save_mesh` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SaveMeshResult {
/// The file written, as given.
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: StlBinary, StlAscii, ThreeMf or ThreeMfTagged.
pub format: String,
/// With `slicer` only: the bodies that were laid on their print face for the slicer.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub print_faces: Vec<String>,
/// With `slicer` only: a warning for each body that has a print face but was saved as it lies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// With `slicer` only: what happened when the file was handed over.
#[serde(flatten)]
pub delivery: SlicerDelivery,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "save_mesh_tests.rs"]
mod tests;
+303
View File
@@ -0,0 +1,303 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::{SaveMeshParams, SaveMeshResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-13308-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn with_box(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})).unwrap();
s.execute("solid.cylinder", &json!({"radius": 3, "height": 8, "body_name": "Pin", "operation": "new"})).unwrap();
s
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SaveMeshParams>(), &["ascii", "bodies", "format", "objects", "path", "quality", "single_instance", "slicer"]);
// `path` is the key the handler refuses when it is left out: required in the schema, optional in the type.
let schema = input_schema_for::<SaveMeshParams>();
assert_eq!(schema.as_value()["required"], json!(["path"]));
assert!(serde_json::from_value::<SaveMeshParams>(json!({})).is_ok());
// The patterns are pinned (`conforms` doesn't read them).
let defs = &schema.as_value()["$defs"];
assert_eq!(defs["MeshFormat"]["pattern"], "^([Ss][Tt][Ll]|3[Mm][Ff]|3[Mm][Ff]-[Tt][Aa][Gg][Gg][Ee][Dd])$");
assert_eq!(defs["MeshPath"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(defs["MeshPath"]["maxLength"], 4095);
let quality = defs["MeshQualityParam"]["pattern"].as_str().unwrap();
assert!(quality.starts_with("^[\\u0009-\\u000d\\u0020") && quality.contains("[Ff][Ii][Nn][Ee][Ss][Tt]"), "{quality}");
assert_eq!(schema.as_value()["allOf"].as_array().map(Vec::len), Some(2));
}
/// Strict gives the handler's message for the calls it refuses for a key. Nothing is written: each
/// of these is refused before the file is.
#[test]
fn strict_keeps_todays_errors() {
let p = |extra: Value| {
let mut p = json!({"path": "/nonexistent-13308/out.stl"});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let mut cases: Vec<(&str, Value)> = vec![
("file.save_mesh", json!({})),
("file.save_mesh", json!({"path": ""})),
("file.save_mesh", json!({"path": " "})),
("file.save_mesh", json!({"path": 5})),
("file.save_mesh", json!({"path": null})),
("file.save_mesh", json!({"path": ["a.stl"]})),
("file.save_mesh", json!({"path": "x".repeat(4096)})),
];
for extra in [
json!({"format": "obj"}),
json!({"format": "step"}),
json!({"format": ""}),
json!({"format": "3MF-Tagged "}),
json!({"quality": "ultra"}),
json!({"quality": 5}),
json!({"quality": true}),
json!({"quality": ""}),
json!({"objects": "Body"}),
json!({"objects": "parts"}),
json!({"objects": 5}),
json!({"objects": false}),
json!({"bodies": 5}),
json!({"bodies": [1, "a"]}),
json!({"bodies": {"a": 1}}),
json!({"bodies": true}),
json!({"format": "stl", "bodies": ["a", null]}),
] {
cases.push(("file.save_mesh", p(extra)));
}
assert_strict_keeps_todays_errors(&cases);
}
/// The refusals only the handler can make (they need the design or two keys together) are the same
/// in strict, and nothing is written.
#[test]
fn strict_keeps_the_handlers_own_refusals() {
let dir = temp_dir("own");
let at = |n: &str| dir.join(n).to_string_lossy().into_owned();
for p in [
json!({"path": at("a.stl"), "objects": "component"}),
json!({"path": at("b.stl"), "bodies": ["Nope"]}),
json!({"path": at("c.3mf"), "slicer": "no such slicer anywhere"}),
json!({"path": at("d.stl"), "slicer": ""}),
] {
let error = |level: ParamCheck| with_box(level).execute("file.save_mesh", &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// The types read each key as the handler does: keys it never reads in a call are not read by the
/// contract either, and the ones it reads fail with its words.
#[test]
fn the_types_read_like_the_handler() {
let reads = |p: Value| serde_json::from_value::<SaveMeshParams>(p).is_ok();
let issues = |p: Value| crate::schema::issues("file.save_mesh", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
// `ascii` is read only for `format: stl`, in any case; `single_instance` only with a text `slicer`.
assert!(reads(json!({"path": "a.stl", "ascii": "yes"})) && reads(json!({"path": "a.stl", "ascii": 1, "format": "3mf"})));
assert!(reads(json!({"path": "a.stl", "ascii": {}, "format": null})) && reads(json!({"path": "a.stl", "ascii": [], "format": null})));
assert!(reads(json!({"path": "a.stl", "single_instance": "no"})) && reads(json!({"path": "a.stl", "single_instance": 0, "slicer": null})));
assert!(reads(json!({"path": "a.stl", "format": "STL", "ascii": true})) && reads(json!({"path": "a.stl", "format": "Stl", "ascii": null})));
assert!(reads(json!({"path": "a.stl", "slicer": "prusa", "single_instance": false})));
assert!(!reads(json!({"path": "a.stl", "format": "stl", "ascii": "yes"})) && !reads(json!({"path": "a.stl", "format": "STL", "ascii": 1})));
assert!(!reads(json!({"path": "a.stl", "slicer": "prusa", "single_instance": "no"})));
// `null` reads as left out where the handler does.
assert!(reads(json!({"path": "a.stl", "format": null, "bodies": null, "slicer": null, "quality": null, "objects": null})));
// Stricter than the handler, which reads a wrong type as left out.
assert!(!reads(json!({"path": "a.stl", "format": 5})) && !reads(json!({"path": "a.stl", "slicer": 5})));
assert!(!reads(json!({"path": "a.stl", "slicer": true})));
// The words the handler takes: any case for the format and the quality (trimmed), lower case for `objects`.
for format in ["stl", "3MF", "3mf-tagged", "3Mf-TaGGed"] {
assert!(reads(json!({"path": "a", "format": format})), "{format}");
}
for quality in ["standard", "FINE", " Finest\n"] {
assert!(reads(json!({"path": "a", "quality": quality})), "{quality:?}");
}
assert!(reads(json!({"path": "a", "objects": "body"})) && reads(json!({"path": "a", "objects": "component"})));
assert!(reads(json!({"path": "a", "bodies": "A"})) && reads(json!({"path": "a", "bodies": ["A", "B"]})));
assert!(reads(json!({"path": "a", "bodies": []})));
// The handler's own messages.
assert_eq!(issues(json!({"path": ""})), ["`path` is required"]);
assert_eq!(issues(json!({"path": 5})), ["`path` is required"]);
assert_eq!(issues(json!({"path": null})), ["`path` is required"]);
assert_eq!(issues(json!({"path": "a", "format": "OBJ"})), ["unknown mesh format `obj` (stl, 3mf, 3mf-tagged)"]);
assert_eq!(issues(json!({"path": "a", "quality": "ultra"})), ["`quality` must be standard, fine or finest, not \"ultra\""]);
assert_eq!(issues(json!({"path": "a", "quality": 5})), ["`quality` must be standard, fine or finest, not 5"]);
assert_eq!(issues(json!({"path": "a", "objects": "Body"})), ["`objects` must be body or component, not \"Body\""]);
assert_eq!(issues(json!({"path": "a", "objects": null})), Vec::<String>::new());
assert_eq!(issues(json!({"path": "a", "bodies": 5})), ["`bodies` must be a list of names; it is 5"]);
assert_eq!(issues(json!({"path": "a", "pth": 1})), ["unknown key `pth`; did you mean `path`?"]);
// A key the handler doesn't read in this call is not checked, so no warning for it.
assert!(issues(json!({"path": "a", "format": "3mf", "ascii": "yes", "single_instance": 3})).is_empty());
assert_eq!(issues(json!({"path": "a", "format": "stl", "ascii": "yes"})), ["`ascii` must be true or false"]);
}
/// What the schema takes: the same calls as the type, including the keys read only in some cases.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<SaveMeshParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.3mf", "format": "3MF", "bodies": ["A"], "quality": "fine", "objects": "component"}), true),
(json!({"path": "a", "format": "stl", "ascii": true}), true),
(json!({"path": "a", "slicer": "prusa", "single_instance": false}), true),
(json!({"path": "a", "bodies": "A"}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a", "format": 5}), false),
(json!({"path": "a", "objects": "x"}), false),
(json!({"path": "a", "bodies": 5}), false),
(json!({"path": "a", "wibble": 1}), false),
// Read only in some cases: anything elsewhere, the shape where it is read.
(json!({"path": "a", "ascii": "yes"}), true),
(json!({"path": "a", "format": "3mf", "ascii": "yes"}), true),
(json!({"path": "a", "format": "stl", "ascii": "yes"}), false),
(json!({"path": "a", "single_instance": "no"}), true),
(json!({"path": "a", "slicer": 5, "single_instance": "no"}), false),
(json!({"path": "a", "slicer": "x", "single_instance": "no"}), false),
] {
// `conforms` doesn't read `pattern`: the cases a pattern alone refuses are in the type tests.
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
fn matches(r: &Value) {
let schema = output_schema_for::<SaveMeshResult>();
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
}
fn keys(r: &Value) -> BTreeSet<String> {
r.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default()
}
/// Every form of the command replies as its output schema says, in warn and without warnings, with
/// the keys it always had; a misspelt key gets its warning and the reply still matches.
#[test]
fn replies_match_the_output_schema() {
let dir = temp_dir("replies");
let at = |n: &str| dir.join(n).to_string_lossy().into_owned();
let mut s = with_box(ParamCheck::Warn);
let run = |s: &mut Session, p: Value| {
let r = s.execute("file.save_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
assert_eq!(keys(&r), ["bytes", "format", "path"].map(String::from).into(), "{p}: {r}");
assert_eq!(r["bytes"].as_u64(), std::fs::metadata(r["path"].as_str().unwrap()).ok().map(|m| m.len()), "{p}: {r}");
r
};
for (p, format) in [
(json!({"path": at("a.stl")}), "StlBinary"),
(json!({"path": at("b.3mf")}), "ThreeMf"),
(json!({"path": at("c.dat")}), "StlBinary"),
(json!({"path": at("d.dat"), "format": "STL"}), "StlBinary"),
(json!({"path": at("e.stl"), "format": "stl", "ascii": true}), "StlAscii"),
(json!({"path": at("f.stl"), "format": "Stl", "ascii": null}), "StlBinary"),
(json!({"path": at("g.stl"), "format": "3mf"}), "ThreeMf"),
(json!({"path": at("h.3mf"), "format": "3MF-tagged"}), "ThreeMfTagged"),
(json!({"path": at("i.stl"), "bodies": "Block", "quality": "Fine"}), "StlBinary"),
(json!({"path": at("j.3mf"), "bodies": ["Block", "Pin"], "quality": " finest ", "objects": "component"}), "ThreeMf"),
(json!({"path": at("k.3mf"), "objects": "body", "bodies": []}), "ThreeMf"),
(json!({"path": at("l.stl"), "format": null, "bodies": null, "quality": null, "objects": null, "slicer": null}), "StlBinary"),
// Keys the handler doesn't read in these calls take anything, and get no warning.
(json!({"path": at("m.3mf"), "ascii": "yes"}), "ThreeMf"),
(json!({"path": at("n.stl"), "single_instance": [1], "ascii": {}}), "StlBinary"),
(json!({"path": at("o.stl"), "format": "3mf", "ascii": 7, "single_instance": "no"}), "ThreeMf"),
] {
let r = run(&mut s, p.clone());
assert_eq!(r["format"], format, "{p}");
}
assert!(std::fs::read_to_string(at("e.stl")).unwrap().starts_with("solid"));
assert!(std::fs::read(at("a.stl")).unwrap().len() > 84);
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
let p = json!({"path": at("p.stl"), "qualty": "fine"});
let r = s.execute("file.save_mesh", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`qualty`")))), "{r}");
matches(&r);
assert_eq!(r["format"], "StlBinary");
s.param_check = ParamCheck::Strict;
let error = s.execute("file.save_mesh", &json!({"path": at("q.stl"), "qualty": "fine"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`qualty`") && error.contains("did you mean `quality`"), "{error}");
assert!(!std::path::Path::new(&at("q.stl")).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// With a `slicer`: the reply says where the file went; a body with a print face is laid on it and
/// listed. The program is a script that records what it is given.
#[cfg(unix)]
#[test]
fn replies_with_a_slicer_match_the_output_schema() {
use std::os::unix::fs::PermissionsExt;
let _turn = crate::tests::mesh_convert::CHOSEN_PROGRAMS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = temp_dir("slicer");
let (script, seen) = (dir.join("fake-slicer"), dir.join("seen"));
std::fs::write(&script, format!("#!/bin/sh\necho \"$@\" >> {}\n", seen.display())).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let spec = script.to_string_lossy().into_owned();
crate::slicers::set_chosen_programs(std::slice::from_ref(&spec));
let mut s = with_box(ParamCheck::Warn);
s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 5]})).unwrap();
let out = dir.join("part.stl").to_string_lossy().into_owned();
let r = s.execute("file.save_mesh", &json!({"path": out, "slicer": spec, "single_instance": false}));
let again =
s.execute("file.save_mesh", &json!({"path": dir.join("x.3mf"), "slicer": spec, "format": "3mf", "objects": "component", "bodies": "Block"}));
crate::slicers::set_chosen_programs(&[]);
let (r, again) = (r.unwrap(), again.unwrap());
for r in [&r, &again] {
assert!(r.get("param_warnings").is_none(), "{r}");
matches(r);
assert!(r["slicer"].is_string() && r.get("slicer_error").is_none(), "{r}");
}
assert_eq!(keys(&r), ["bytes", "format", "path", "print_faces", "slicer"].map(String::from).into(), "{r}");
assert_eq!(r["print_faces"], json!(["Block"]));
assert_eq!(r["path"], json!(out));
assert_eq!(keys(&again), ["bytes", "format", "path", "print_faces", "slicer"].map(String::from).into(), "{again}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The typed reply serializes to the keys the handler's JSON had, and nothing for what is unset.
#[test]
fn the_reply_type_serializes_as_before() {
use super::{SaveMeshResult, SlicerDelivery};
let reply = |delivery| SaveMeshResult {
path: "a.stl".into(),
bytes: 5,
format: "StlBinary".into(),
print_faces: Vec::new(),
warnings: Vec::new(),
delivery,
param_warnings: Vec::new(),
};
assert_eq!(serde_json::to_value(reply(SlicerDelivery::default())).unwrap(), json!({"path": "a.stl", "bytes": 5, "format": "StlBinary"}));
// Print faces and warnings are there when there are any, as `Laid::report` put them.
let laid = SaveMeshResult { print_faces: vec!["A".into()], warnings: vec!["w".into()], ..reply(SlicerDelivery::default()) };
let r = serde_json::to_value(laid).unwrap();
assert_eq!(r, json!({"path": "a.stl", "bytes": 5, "format": "StlBinary", "print_faces": ["A"], "warnings": ["w"]}));
matches(&r);
for (delivery, key) in [
(SlicerDelivery { slicer: Some("P".into()), ..Default::default() }, "slicer"),
(SlicerDelivery { slicer_error: Some("e".into()), ..Default::default() }, "slicer_error"),
(SlicerDelivery { starting: Some("P".into()), ..Default::default() }, "starting"),
(SlicerDelivery { slicer_timeout: Some("P".into()), ..Default::default() }, "slicer_timeout"),
] {
let r = serde_json::to_value(reply(delivery)).unwrap();
assert_eq!(keys(&r), ["bytes", "format", "path", key].map(String::from).into(), "{r}");
matches(&r);
}
}
@@ -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}");
}
}
}
+75
View File
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
use super::check::schema_keys;
use super::features::{BoxParams, ExtrudeParams};
use super::types::{RevolveAxis, RevolveConventionParam, RevolveDirection, RevolveToPoint};
use super::*;
use crate::Session;
@@ -1136,6 +1137,80 @@ fn axis_lines_match_the_axis_reader() {
}
}
/// A revolve's `axis` is accepted as `features::axis_param` reads it before it looks anything up,
/// and its schema says the same, apart from what a schema can't say (below).
#[test]
fn revolve_axes_match_the_axis_reader() {
let schema = input_schema_for::<RevolveAxis>();
let schema = schema.as_value();
for (v, accepted) in [
(json!("y"), true),
(json!("Z"), true),
(json!("z"), true),
(json!("l1"), true),
(json!("Spindle"), true),
(json!(""), true),
(json!({"dir": [0, 0, 1]}), true),
(json!({"dir": [0, 0, 0]}), true),
(json!({"origin": [1, 2, 3], "dir": {"x": 0, "y": 1, "z": 0}}), true),
(json!({"origin": ["1 mm", 0, {"x": 1}], "dir": [0, 0, 1]}), false),
(json!({"origin": ["1 mm", "w / 2", 0], "dir": [0, 0, 1]}), true),
(json!({"origin": {"x": 1, "y": 2, "z": "3 mm"}, "dir": [0, 0, 1]}), true),
(json!({"origin": "o", "dir": [0, 0, 1]}), false),
(json!({"origin": null, "dir": [0, 0, 1]}), false),
(json!({"origin": [1, 2], "dir": [0, 0, 1]}), false),
(json!({"origin": [0, 0, 0]}), false),
(json!({"dir": [0, 1]}), false),
(json!({"dir": "y"}), false),
(json!({"dir": [0, 0, 1], "type": "sketch_line", "extra": 1}), true),
(json!({"type": "world", "axis": "X"}), true),
(json!({"type": "world", "axis": "X", "origin": 5, "dir": "q"}), true),
(json!({"type": "world", "axis": "x"}), false),
(json!({"type": "world", "axis": 5}), false),
(json!({"type": "world"}), false),
(json!({"type": "world", "dir": [0, 0, 1]}), false),
(json!({"edge": [1, 2, 3]}), true),
(json!({"face": [1, 2, 3], "dir": [0, 0, 1]}), true),
(json!({"face": "x", "dir": [0, 0, 1]}), false),
(json!({"type": "face", "at": [1, 2, 3]}), true),
(json!({"type": "vertex", "at": [1, 2, 3]}), false),
(json!({"face": "x", "vertex": [1, 2, 3]}), false),
(json!({}), false),
(json!(5), false),
(json!(true), false),
(json!(null), false),
(json!([0, 0, 1]), false),
] {
assert_eq!(parse::<RevolveAxis>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(schema, schema, &v), accepted, "schema {v}");
}
// Where the schema can't follow the reader: a saved edge is read whole by serde.
let v = json!({"type": "edge", "at": "x"});
assert!(parse::<RevolveAxis>(v.clone()).is_none() && conforms(schema, schema, &v), "{v}");
}
/// Revolve's `direction` and `convention` take what the handler reads, and its `to` is a point.
#[test]
fn revolve_keywords_match_the_handler() {
for (v, accepted) in
[(json!("symmetric"), true), (json!("SYMMETRIC"), true), (json!(" symmetric"), false), (json!("negative"), false), (json!(true), false)]
{
assert_eq!(parse::<RevolveDirection>(v.clone()).is_some(), accepted, "{v}");
let schema = input_schema_for::<RevolveDirection>();
assert_eq!(conforms(schema.as_value(), schema.as_value(), &v), accepted && v == json!("symmetric"), "schema {v}");
}
for (v, accepted) in [
(json!("sketch_normal"), true),
(json!("legacy_axis_start"), true),
(json!("Sketch_Normal"), false),
(json!("legacy_per_side"), false),
(json!(null), false),
] {
assert_eq!(parse::<RevolveConventionParam>(v.clone()).is_some(), accepted, "{v}");
}
assert!(parse::<RevolveToPoint>(json!([1, 2, 3])).is_some() && parse::<RevolveToPoint>(json!([1, 2])).is_none());
}
/// A construction plane's name is refused when `features::check_plane_name` refuses it. (The
/// schema can't say which names: see `PlaneName`.)
#[test]
+180 -6
View File
@@ -210,6 +210,12 @@ target_point!(
"must be a point [x, y, z] on the face or point to start from"
);
target_point!(
/// Revolve's `to`: a [`Point3`], with the handler's message.
RevolveToPoint,
"must be a point [x, y, z] to turn to"
);
/// Extrude's `faces`: 1 to 1024 points on coplanar faces of one body, read as the handler reads
/// them, with its messages.
#[derive(Clone, Debug, PartialEq)]
@@ -974,6 +980,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`
@@ -1324,6 +1366,17 @@ impl JsonSchema for PlaneOrFace {
}
}
/// What `construct::model_axis_param` finds wrong with an edge or face axis before it looks
/// anything up in the design: a reference that isn't a picked or saved face or edge.
fn model_axis_problem(v: &Value) -> Option<String> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
geo_problem(v).or_else(|| match crate::cmd::geo_form(v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
}
/// The line `construct.plane.at_angle` turns about, as `features::axis_line` reads it: an edge or
/// face of the model (as `construct::model_axis_param` reads it), else X, Y, Z, a construction
/// axis's name or `{origin?, dir}`. Kept as given, for the handler. Whether a picked edge or face
@@ -1333,14 +1386,9 @@ pub struct AxisLine(pub Value);
impl<'de> Deserialize<'de> for AxisLine {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
geo_problem(&v).or_else(|| match crate::cmd::geo_form(&v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
model_axis_problem(&v)
} else {
match &v {
Value::String(_) => None,
@@ -1380,6 +1428,132 @@ impl JsonSchema for AxisLine {
}
}
/// What `features::axis_param` says to an `axis` that is none of its forms.
const REVOLVE_AXIS_MESSAGE: &str =
"`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}";
/// What `features::axis_param` finds wrong with an object axis that isn't an edge or face of the
/// model, before it looks anything up in the design.
fn revolve_axis_object_problem(o: &Value) -> Option<String> {
if o.get("type").and_then(Value::as_str) == Some("world") {
let ok = o.get("axis").and_then(Value::as_str).is_some_and(|a| matches!(a, "X" | "Y" | "Z"));
return (!ok).then(|| whole("world axis requires `axis` to be X, Y or Z"));
}
if let Some(origin) = o.get("origin")
&& serde_json::from_value::<PointExpr3>(origin.clone()).is_err()
{
return Some(whole("`origin` must be a point [x, y, z] of numbers or length expressions"));
}
match o.get("dir") {
None => Some(whole("axis needs `dir`")),
Some(dir) => params::vec3(dir).is_none().then(|| whole("axis `dir` must be a direction [x, y, z] of numbers")),
}
}
/// Revolve's `axis`, as `features::axis_param` reads it before it looks anything up: a sketch
/// line's id or name, `x` or `y`, X, Y or Z, a construction axis's name (any text), `{type: "world",
/// axis}`, `{origin?, dir}`, or an edge or face of the model (as `construct::model_axis_param`
/// reads it). Kept as given, for the handler. Whether a line or axis exists, and whether a picked
/// edge or face gives an axis, is checked by the handler, which has the design.
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveAxis(pub Value);
impl<'de> Deserialize<'de> for RevolveAxis {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
model_axis_problem(&v)
} else {
match &v {
Value::String(_) => None,
Value::Object(_) => revolve_axis_object_problem(&v),
_ => Some(whole(REVOLVE_AXIS_MESSAGE)),
}
};
match problem {
Some(why) => Err(D::Error::custom(why)),
None => Ok(RevolveAxis(v)),
}
}
}
impl JsonSchema for RevolveAxis {
fn schema_name() -> Cow<'static, str> {
"RevolveAxis".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let origin = generator.subschema_for::<PointExpr3>();
let model = json!({"anyOf": [{"required": ["edge"]}, {"required": ["face"]}, {"required": ["type"], "properties": {"type": {"enum": ["edge", "face"]}}}]});
json_schema!({
"description": "The axis to turn about: a sketch line's id or name (a line of the profile's sketch wins over a construction axis of the same name); x or y, the sketch's own axes; X, Y or Z, the world axes (any other case is a name); a construction axis's name, which the revolve follows when it is edited; {\"type\": \"world\", \"axis\": X, Y or Z}, as timeline.inspect gives one back; {\"origin\"?: [x, y, z] (numbers or length expressions; default: the world origin), \"dir\": [x, y, z]}, which the revolve follows when parameters change; or an edge or face of a body ({\"edge\": [x, y, z]} or {\"face\": [x, y, z]}, a point on a straight or circular edge, or on a cylinder, cone or torus face, or as saved: {\"type\": \"edge\" or \"face\", \"at\", \"name\"?}), about whose axis it turns, followed as the model changes.",
"anyOf": [
{"type": "string"},
{"type": "object", "properties": {"type": {"enum": ["world"]}, "axis": {"enum": ["X", "Y", "Z"]}}, "required": ["type", "axis"]},
{
"type": "object",
"properties": {"origin": origin, "dir": point},
"required": ["dir"],
"not": {"anyOf": [model, {"required": ["type"], "properties": {"type": {"enum": ["world"]}}}]}
},
picked_at("face", &point),
picked_at("edge", &point),
{"type": "object", "properties": {"type": {"enum": ["edge", "face"]}}, "required": ["type"]}
]
})
}
}
/// Revolve's `direction`: only `symmetric` (any case, not trimmed) does anything; the handler
/// ignores every other value.
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveDirection;
impl<'de> Deserialize<'de> for RevolveDirection {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if s.eq_ignore_ascii_case("symmetric") => Ok(RevolveDirection),
_ => Err(D::Error::custom("must be symmetric")),
}
}
}
impl JsonSchema for RevolveDirection {
fn schema_name() -> Cow<'static, str> {
"RevolveDirection".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["symmetric"],
"symmetric: the angle is split either side of the sketch plane, whichever way the axis points. The same as `symmetric: true`. Matching ignores case.",
)
}
}
/// Revolve's angular start, as the handler reads `convention` (serde, case-sensitive).
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveConventionParam(pub solidcraft_doc::RevolveConvention);
impl<'de> Deserialize<'de> for RevolveConventionParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
serde_json::from_value(Value::deserialize(d)?)
.map(RevolveConventionParam)
.map_err(|_| D::Error::custom("must be sketch_normal or legacy_axis_start"))
}
}
impl JsonSchema for RevolveConventionParam {
fn schema_name() -> Cow<'static, str> {
"RevolveConvention".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["sketch_normal", "legacy_axis_start"],
"sketch_normal (the default): the angle is measured from the sketch plane, turning about the axis as the sketch's normal sweeps. legacy_axis_start keeps the angular start older designs had.",
)
}
}
/// A construction plane's name, which `features::check_plane_name` refuses when it names an
/// origin plane. Strict gives the same message but names the command that was called, where the
/// handler (`add_feature`, which isn't given it) says `construct.plane`.
-8
View File
@@ -7,11 +7,8 @@ doc.recover
doc.recovery_discard
doc.recovery_list
face.delete
file.export
file.insert_mesh
file.insert_step
file.new
file.save_mesh
file.slicers
motion.export_animation
motion.trace
@@ -24,8 +21,6 @@ render.preflight
render.scene
render.start
sheet.export_dxf
sketch.draw.dimensioned
solid.align
solid.coil
solid.draft
solid.emboss
@@ -39,10 +34,7 @@ solid.pattern.path
solid.pattern.rectangular
solid.press_pull
solid.replace_face
solid.revolve
solid.shell
solid.split_body
solid.split_face
solid.thread
surface.blend
surface.extend
+1 -1
View File
@@ -235,7 +235,7 @@ fn send_to_slicer_needs_a_slicer_and_bodies() {
/// The chosen-programs list is process-wide: tests that set it take turns.
#[cfg(unix)]
static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// `file.save_mesh` with a `slicer` writes where the caller said and hands that file over, by the
/// same steps as `file.send_to_slicer`.
+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}°");
}
}
+57
View File
@@ -1528,3 +1528,60 @@ fn a_modelled_thread_leaves_the_next_hole_a_cylinder_to_thread() {
assert!(t3.is_ok(), "gap {gap}: the second hole takes a modelled thread: {t3:?}");
}
}
/// #13505: a tapped hole drilled across two slots has its wall in three pieces (z 30..40, 15..25
/// and 5..10). The thread runs along every piece the hole reaches, cosmetic or modelled, and a
/// thread depth runs on through the pieces from the first one's start.
#[test]
fn a_tapped_hole_across_slots_is_threaded_along_every_piece_of_its_wall() {
let slotted = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 30, "width": 30, "height": 40, "body_name": "Part"}));
for z in [25, 10] {
run(&mut s, "solid.box", json!({"corner": [-1, -1, z], "length": 32, "width": 21, "height": 5, "operation": "cut", "targets": ["Part"]}));
}
s
};
let hole = |s: &mut Session, extra: Value| {
let mut ask = json!({"position": [15, 10, 40], "direction": [0, 0, -1], "thread": "M4", "depth": 35});
ask.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
run(s, "solid.hole", ask);
};
let spans = |s: &Session| {
let mut v: Vec<(f64, f64)> = s
.model
.state()
.threads
.iter()
.map(|t| {
let (a, b) = ((t.axis_point + t.axis * t.start).z, (t.axis_point + t.axis * t.end).z);
(a.min(b), a.max(b))
})
.collect();
v.sort_by(|a, b| b.0.total_cmp(&a.0));
v
};
let near = |got: Vec<(f64, f64)>, want: &[(f64, f64)]| {
assert_eq!(got.len(), want.len(), "{got:?} vs {want:?}");
for (g, w) in got.iter().zip(want) {
assert!((g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6, "{got:?} vs {want:?}");
}
};
let mut s = slotted();
hole(&mut s, json!({}));
near(spans(&s), &[(30.0, 40.0), (15.0, 25.0), (5.0, 10.0)]);
// 22 mm from the entry: on through the first slot, stopping in the second piece.
let mut s = slotted();
hole(&mut s, json!({"thread_depth": 22}));
near(spans(&s), &[(30.0, 40.0), (18.0, 25.0)]);
// Modelled, every piece of the wall is grooved: each loses material the cosmetic one keeps.
let mut s = slotted();
hole(&mut s, json!({"modeled": true}));
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty());
let mut plain = slotted();
hole(&mut plain, json!({}));
for (z0, z1) in [(30.0, 40.0), (15.0, 25.0), (5.0, 10.0)] {
let (cut, drilled) = (volume_between(&s, "Part", (z0, z1), None), volume_between(&plain, "Part", (z0, z1), None));
assert!(cut < drilled - 1.0, "z {z0}..{z1}: {cut} vs {drilled}");
}
}
+6 -5
View File
@@ -432,11 +432,12 @@ mod tests {
assert!(classify(&body, &mesh, 0).0.iter().all(|f| !*f));
}
/// A G2 corner patch's sides are G2 to their rounds except near the faces they end on, and
/// SMLib holds each side as two edges: the side is one join, drawn whole, not up to the
/// vertex in its middle where the G2 half begins (the 20 mm box's r3 corner).
/// A G2 corner patch's sides are G2 to their rounds except near the faces they end on: each
/// side is one join, drawn whole (the 20 mm box's r3 corner). Since tangency-labs/solidcore#2988 the
/// corner SMLib leaves open is the blend corner's Setback, whose sections are not split, so
/// each side is one edge (the N x N patch before it held each side as two).
#[test]
fn a_seam_split_in_two_edges_is_drawn_whole() {
fn a_g2_corner_s_side_to_its_round_is_drawn_whole() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(20., 20., 20.)).unwrap();
let picks = [Vec3::new(20., 20., 10.), Vec3::new(20., 10., 20.), Vec3::new(10., 20., 20.)];
let g2 = crate::fillet_g2_corners(&body, &picks, 3., &[]).unwrap();
@@ -452,7 +453,7 @@ mod tests {
for round in rounds {
let side: Vec<usize> =
(0..mesh.edges.len()).filter(|e| mesh.edge_faces[*e].contains(&patch) && mesh.edge_faces[*e].contains(&round)).collect();
assert_eq!(side.len(), 2, "SMLib splits the side in two: {side:?}");
assert_eq!(side.len(), 1, "the Setback's section is one edge (tangency-labs/solidcore#2988): {side:?}");
assert!(side.iter().all(|e| mesh.shaded_edge_visible(*e)), "the side to round {round} is drawn whole: {side:?}");
}
}
+20
View File
@@ -2487,6 +2487,26 @@ fn compact_execute_keeps_changes_ids_and_default_replies() {
}
}
/// #9180: a compact sketch reply counts the points of a spline's poles instead of listing them,
/// and keeps the point ids of curves with only their ends.
#[test]
fn compact_sketch_replies_count_spline_poles() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
let points = json!([[0, 0], [10, 5], [20, 0], [30, 5], [40, 0], [50, 5]]);
let full = tool(&mut s, "execute", json!({"command": "sketch.spline.fit_point", "params": {"points": points}}));
let listed = full["created"]["points"].as_array().unwrap().len();
let curves = full["created"]["curves"].as_array().unwrap().len();
assert!(listed > 2 * curves, "{full}");
let compact = tool(&mut s, "execute", json!({"command": "sketch.spline.fit_point", "params": {"points": points}, "verbose": false}));
assert_eq!(compact["created"]["point_count"], json!(listed), "{compact}");
assert!(compact["created"].get("points").is_none(), "{compact}");
assert_eq!(compact["created"]["curves"].as_array().map(Vec::len), Some(curves));
let rectangle =
tool(&mut s, "execute", json!({"command": "sketch.rectangle.two_point", "params": {"p0": [0, 20], "p1": [10, 30]}, "verbose": false}));
assert_eq!(rectangle["created"]["points"].as_array().map(Vec::len), Some(4), "{rectangle}");
}
/// A connected app sees exactly one engine call; no inventory inspection is needed.
#[test]
fn compact_execute_uses_one_backend_call_and_keeps_removed_bodies() {
+12 -2
View File
@@ -122,7 +122,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
json!({
"command": string("Command id (see list_commands)"),
"params": {"type": "object", "description": "The command's parameters"},
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies (default true)"},
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies. When a sketch command creates more points than its new curves have ends (spline poles), `created.points` becomes `created.point_count` (default true)"},
"expect": expect_schema(),
}),
&["command"],
@@ -160,7 +160,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
"coalesce_parameters": {"type": "boolean", "description": "Set each run of consecutive plain parameter edits (parameters.change or set_parameter with a name and a value, or changes) together, rebuilding the design once for the run instead of once per edit; a run that is refused goes step by step, naming the refused step. Other steps still see the design up to date. Each step keeps its own result; the run is one undo step (default false)"},
"timings": {"type": "boolean", "description": "Add `timings`: [{index, command, ms}] for every step run (the failing one included) and `total_ms` (default false)"},
"profile": {"type": "boolean", "description": "Like timings, and each step's `profile`: {boolean_ms, tessellate_ms, other_ms}, its time in kernel booleans, in tessellation and the rest (default false)"},
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created` (default true)"},
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created`, and `created.points` becomes `created.point_count` when there are more points than the new curves have ends (default true)"},
"expect": expect_schema(),
}),
&[],
@@ -1829,6 +1829,16 @@ fn compact(mut v: Value) -> Value {
}
}
}
// More new points than the new curves have ends (an offset's spline poles, #9180): agents
// refer to those curves by id, so the points only fill the context. Keep how many there were.
if let Some(created) = o.get_mut("created").and_then(Value::as_object_mut) {
let count = |k: &str| created.get(k).and_then(Value::as_array).map_or(0, Vec::len);
let (points, curves) = (count("points"), count("curves"));
if curves > 0 && points > 2 * curves {
created.remove("points");
created.insert("point_count".into(), json!(points));
}
}
v
}
+7 -6
View File
@@ -606,9 +606,10 @@ fn pocket_chamfer_on_a_boolean_body() {
}
/// The corner of r5 rounds at (60, 40, 20) on a 60 x 40 x 20 box, set back 8 and left open: the
/// box's six faces and the three rounds (9 faces, 30 edges, 22 vertices), a sheet, and one hole
/// of twelve edges (each section and face curve in two), chained end to start, each beside a face
/// of the sheet.
/// box's six faces and the three rounds (9 faces, 27 edges, 19 vertices), a sheet, and one hole
/// of nine edges, chained end to start, each beside a face of the sheet. The marked corner is the
/// blend corner's Setback (tangency-labs/solidcore#2988), whose sections and face curves are one
/// edge each; the N x N patch it replaced split each in two (12 edges, 30 and 22).
#[test]
fn a_setback_corner_left_open_returns_its_hole() {
let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
@@ -616,17 +617,17 @@ fn a_setback_corner_left_open_returns_its_hole() {
let b = Shape::Box([0.0; 3], [60.0, 40.0, 20.0]).core();
blend_holes(&b, Blend::Round, &edges, &[5.0; 3], 0.1, &[[60.0, 40.0, 20.0]], &[8.0], Corners::Open).unwrap()
});
assert_eq!(counts(&open).unwrap(), [9, 30, 22]);
assert_eq!(counts(&open).unwrap(), [9, 27, 19]);
assert!(!super::is_manifold_solid(&open).unwrap());
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
assert_eq!(hole.edges.len(), 12);
assert_eq!(hole.edges.len(), 9);
let flat = to_arrays(&open).unwrap();
let (rec, _) = native(&flat).unwrap();
solidcraft_geom::brep::check::validate(&rec).unwrap();
let ends = &flat.ints[&crate::flat::EDGE_VERTICES];
let end = |h: &crate::HoleEdge, last: bool| ends[2 * h.edge + usize::from(h.forward == last)];
for (k, h) in hole.edges.iter().enumerate() {
let next = &hole.edges[(k + 1) % 12];
let next = &hole.edges[(k + 1) % 9];
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
assert!(h.face < 9);
}
+8 -6
View File
@@ -389,24 +389,26 @@ fn draft_tilts_a_side_about_its_line_on_the_neutral_plane() {
}
/// The corner of r5 rounds at (60, 40, 20) on a 60 x 40 x 20 box, set back 8 and left open: the
/// box's six faces and the three rounds (9 faces, 30 edges, 22 vertices), a sheet SMLib's check
/// passes, and one hole of twelve edges (each section and face curve in two), chained end to
/// start, each bounding a different face than the patch's neighbours across it.
/// box's six faces and the three rounds (9 faces, 27 edges, 19 vertices), a sheet SMLib's check
/// passes, and one hole of nine edges, chained end to start, each bounding a different face than
/// the patch's neighbours across it. The marked corner is the blend corner's Setback
/// (tangency-labs/solidcore#2988), whose sections and face curves are one edge each; the N x N
/// patch it replaced split each in two (12 edges, 30 and 22).
#[test]
fn a_setback_corner_left_open_returns_its_hole() {
let b = Brep::make_box([0.0; 3], [60.0, 40.0, 20.0]).unwrap();
let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
let (open, holes) = b.blend_setback_open(Blend::Round, &edges, &[5.0; 3], &[[60.0, 40.0, 20.0]], &[8.0], 0.1).unwrap();
shot(&open, "setback_open");
assert_eq!(open.counts().unwrap(), [9, 30, 22]);
assert_eq!(open.counts().unwrap(), [9, 27, 19]);
assert!(!open.is_manifold_solid().unwrap());
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
assert_eq!(hole.edges.len(), 12);
assert_eq!(hole.edges.len(), 9);
let arrays = open.arrays().unwrap();
let ends = &arrays.ints[&crate::flat::EDGE_VERTICES];
let end = |h: &HoleEdge, last: bool| ends[2 * h.edge + usize::from(h.forward == last)];
for (k, h) in hole.edges.iter().enumerate() {
let next = &hole.edges[(k + 1) % 12];
let next = &hole.edges[(k + 1) % 9];
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
assert!(h.face < 9);
}
+1
View File
@@ -70,6 +70,7 @@ proc-macro2 = { workspace = true }
# egui-winit's clipboard.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
arboard = { version = "3.6", default-features = false, features = ["image-data"] }
lopdf = { version = "0.45", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
# The offscreen viewport render test opens a device without a window.
+211 -26
View File
@@ -30,6 +30,11 @@ const MAX_PICTURE_BYTES: u64 = 40 * 1024 * 1024;
const MAX_TEXT_BYTES: u64 = 256 * 1024;
/// Largest PDF sent as a document; a bigger one goes by reference.
const MAX_PDF_BYTES: u64 = 12 * 1024 * 1024;
/// Pages whose text is taken from a PDF, and the most characters of it kept.
const MAX_PDF_PAGES: usize = 300;
const MAX_PDF_CHARS: usize = 200_000;
/// A PDF page's content may inflate to at most this (a compressed bomb is refused by the reader).
const MAX_PDF_PAGE_BYTES: usize = 32 * 1024 * 1024;
/// A picture's longer side is scaled down to this before it is sent.
pub const MAX_SIDE: u32 = 2048;
/// Largest picture sent, as it came or re-encoded: its base64 (a third larger) stays under 5 MB,
@@ -88,10 +93,11 @@ pub enum Content {
size: [u32; 2],
},
Text(Arc<String>),
/// A PDF, sent whole as a document to an agent that takes them.
/// A PDF, sent whole as a document to an agent that takes them; to the others, its text.
Pdf {
bytes: Arc<Vec<u8>>,
path: PathBuf,
text: Option<Arc<PdfText>>,
},
/// A file the agent is pointed to, not given: what it is, its size and where it is.
Reference {
@@ -103,6 +109,60 @@ pub enum Content {
Imported,
}
/// What was taken out of a PDF: its page count and its text, without layout, pictures or fonts.
#[derive(Clone, Debug, PartialEq)]
pub struct PdfText {
pub pages: usize,
pub text: String,
/// The text stops short: of the pages or of the characters kept.
pub cut: bool,
}
/// The text of a PDF, from its pages in order (up to [`MAX_PDF_PAGES`] pages and
/// [`MAX_PDF_CHARS`] characters). `None` when it can't be read at all (damaged, or a reader
/// panic, which is caught); an encrypted or scanned one gives its page count and no text.
#[cfg(not(target_arch = "wasm32"))]
pub fn pdf_text(bytes: &[u8]) -> Option<PdfText> {
std::panic::catch_unwind(|| {
let doc = lopdf::Document::load_mem(bytes).ok()?;
let pages: Vec<u32> = doc.get_pages().keys().copied().collect();
let mut text = String::new();
let mut chars = 0usize;
let mut cut = pages.len() > MAX_PDF_PAGES;
if !doc.is_encrypted() {
for page in pages.iter().take(MAX_PDF_PAGES) {
// A page that can't be read is skipped; the others still give their text.
let chunks = doc.extract_text_chunks_with_limit(&[*page], MAX_PDF_PAGE_BYTES);
let page_text: String = chunks.into_iter().filter_map(Result::ok).collect();
let page_text = page_text.trim();
if page_text.is_empty() {
continue;
}
if !text.is_empty() {
text.push_str("\n\n");
}
let room = MAX_PDF_CHARS.saturating_sub(chars);
let n = page_text.chars().count();
if n > room {
text.extend(page_text.chars().take(room));
cut = true;
break;
}
text.push_str(page_text);
chars += n;
}
}
Some(PdfText { pages: pages.len(), text, cut })
})
.ok()
.flatten()
}
#[cfg(target_arch = "wasm32")]
pub fn pdf_text(_bytes: &[u8]) -> Option<PdfText> {
None
}
#[derive(Clone, Debug, PartialEq)]
pub struct Attachment {
/// Its own on the message, never reused (set when it is added there; 0 until then): the
@@ -222,7 +282,9 @@ pub fn read(path: &Path) -> Result<Attachment, String> {
return Ok(Attachment { id: 0, name, content: Content::Reference { path, size: meta.len(), mime: "application/pdf" }, thumb: None });
}
let bytes = std::fs::read(&path).map_err(|e| failed(&name, e))?;
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path }, thumb: None })
// Read here, off the window thread: for an agent that takes no documents.
let text = pdf_text(&bytes).map(Arc::new);
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path, text }, thumb: None })
}
Kind::Other if matches!(path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("heic" | "heif")) => {
Err(refused("ui.agent_chat_file_heic", &name, &[]))
@@ -334,6 +396,24 @@ fn thumbnail(img: &image::DynamicImage) -> Option<Arc<egui::ColorImage>> {
Some(Arc::new(egui::ColorImage::from_rgba_unmultiplied(size, t.as_raw())))
}
/// Add a file's text to the message under `head`, within the `left` characters shared by the
/// files: whole when it fits; cut, saying how much is missing, when it doesn't; named only when
/// nothing is left.
fn fit(out: &mut String, left: &mut usize, head: &str, text: &str) {
let t = text.trim_end();
let len = t.chars().count();
if *left == 0 {
*out += &format!("\n\n{head}: left out, the message is at its size limit.");
} else if len <= *left {
*out += &format!("\n\n{head}:\n```\n{t}\n```");
*left -= len;
} else {
let kept: String = t.chars().take(*left).collect();
*out += &format!("\n\n{head} (cut: {} more characters left out):\n```\n{kept}\n```", len - *left);
*left = 0;
}
}
/// What the message says about its files, after the person's words: the text files' text, and the
/// pictures and imported parts by name (the agent sees the pictures themselves as images).
/// `pictures_sent` and `documents_sent`: whether this agent takes pictures and PDFs. The text files share `budget` characters
@@ -353,21 +433,8 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
let mut left = budget;
for f in files {
match &f.content {
Content::Text(t) => {
let t = t.trim_end();
let len = t.chars().count();
if left == 0 {
out += &format!("\n\nAttached file `{}`: left out, the message is at its size limit.", f.name);
} else if len <= left {
out += &format!("\n\nAttached file `{}`:\n```\n{t}\n```", f.name);
left -= len;
} else {
let kept: String = t.chars().take(left).collect();
out += &format!("\n\nAttached file `{}` (cut: {} more characters left out):\n```\n{kept}\n```", f.name, len - left);
left = 0;
}
}
Content::Pdf { path, bytes } if documents_sent => {
Content::Text(t) => fit(&mut out, &mut left, &format!("Attached file `{}`", f.name), t),
Content::Pdf { path, bytes, .. } if documents_sent => {
out += &format!(
"\n\nAttached PDF `{}` ({}, at {}): sent with this message as a document.",
f.name,
@@ -375,14 +442,29 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
path.display()
);
}
// An agent that takes no documents is pointed to the file, as for any other file.
Content::Pdf { path, bytes } => {
out += &format!(
"\n\nAttached file `{}` (PDF, {}, at {}): read it with your own tools; it is not in this message.",
f.name,
size_text(bytes.len() as u64),
path.display()
);
// An agent that takes no documents gets the text, or is pointed to the file.
Content::Pdf { path, bytes, text } => {
let size = size_text(bytes.len() as u64);
match text.as_deref().filter(|t| !t.text.trim().is_empty()) {
Some(t) => {
let cut = if t.cut { "; cut, the rest is in the file" } else { "" };
let head = format!(
"Attached PDF `{}` ({} pages, {size}, at {}): its text only, layout, pictures and fonts are dropped{cut}",
f.name,
t.pages,
path.display()
);
fit(&mut out, &mut left, &head, &t.text);
}
None => {
let why = if text.is_some() { "no text could be taken from it (scanned or protected)" } else { "its text could not be read" };
out += &format!(
"\n\nAttached file `{}` (PDF, {size}, at {}): {why}; read it with your own tools, it is not in this message.",
f.name,
path.display()
);
}
}
}
Content::Reference { path, size, mime } => {
out += &format!(
@@ -405,7 +487,7 @@ pub fn parts(files: &[Attachment], pictures: bool, documents: bool) -> Vec<Promp
.iter()
.filter_map(|f| match &f.content {
Content::Picture { mime, bytes, .. } if pictures => Some(PromptPart::Image { mime: (*mime).to_string(), base64: backend::base64(bytes) }),
Content::Pdf { bytes, path } if documents => Some(PromptPart::Document {
Content::Pdf { bytes, path, .. } if documents => Some(PromptPart::Document {
name: f.name.clone(),
uri: file_uri(path),
mime: "application/pdf".to_string(),
@@ -680,4 +762,107 @@ mod tests {
assert_eq!(size_text(1536), "2 KB");
assert_eq!(size_text(5 * 1024 * 1024 + 512 * 1024), "5.5 MB");
}
/// A PDF with one line of text on each page.
fn sample_pdf(pages: &[&str]) -> Vec<u8> {
use lopdf::content::{Content, Operation};
use lopdf::{Document, Object, Stream, dictionary};
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let font = doc.add_object(dictionary! {"Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica"});
let resources = doc.add_object(dictionary! {"Font" => dictionary! {"F1" => font}});
let mut kids = Vec::new();
for line in pages {
let mut ops = vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec!["F1".into(), 12.into()]),
Operation::new("Td", vec![72.into(), 700.into()]),
];
if !line.is_empty() {
ops.push(Operation::new("Tj", vec![Object::string_literal(*line)]));
}
ops.push(Operation::new("ET", vec![]));
let content = doc.add_object(Stream::new(dictionary! {}, Content { operations: ops }.encode().unwrap()));
kids.push(Object::Reference(doc.add_object(dictionary! {"Type" => "Page", "Parent" => pages_id, "Contents" => content})));
}
let tree = dictionary! {
"Type" => "Pages", "Kids" => kids, "Count" => pages.len() as i64, "Resources" => resources,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
};
doc.objects.insert(pages_id, Object::Dictionary(tree));
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id});
doc.trailer.set("Root", catalog);
let mut out = Vec::new();
doc.save_to(&mut out).unwrap();
out
}
/// An agent that takes no documents gets the PDF's text, with its page count and a note that
/// the layout was dropped; one that takes documents gets the PDF and not the text.
#[test]
fn a_pdf_gives_its_text_to_agents_without_documents() {
let d = dir("pdftext");
let path = d.join("brief.pdf");
std::fs::write(&path, sample_pdf(&["Wall 2 mm", "Hole 5 mm"])).unwrap();
let a = read(&path).unwrap();
let Content::Pdf { text: Some(t), .. } = &a.content else { panic!("a PDF with its text: {:?}", a.content) };
assert_eq!(t.pages, 2);
assert!(!t.cut && t.text.contains("Wall 2 mm") && t.text.contains("Hole 5 mm"), "{:?}", t.text);
assert!(t.text.find("Wall").unwrap() < t.text.find("Hole").unwrap(), "pages in order");
let files = vec![a];
let n = note(&files, false, false, usize::MAX);
assert!(n.contains("Attached PDF `brief.pdf` (2 pages"), "{n}");
assert!(n.contains("layout, pictures and fonts are dropped") && n.contains("Wall 2 mm"), "{n}");
let short = note(&files, false, false, 5);
assert!(short.contains("more characters left out"), "the text shares the message's budget: {short}");
let with = note(&files, false, true, usize::MAX);
assert!(with.contains("as a document") && !with.contains("Wall 2 mm"), "{with}");
let _ = std::fs::remove_dir_all(&d);
}
/// A PDF that is all pictures (a scan), or can't be read, says so and points to the file; the
/// text kept is capped.
#[test]
fn pdfs_without_text_or_too_long_are_said_so() {
let blank = pdf_text(&sample_pdf(&[""])).unwrap();
assert_eq!((blank.pages, blank.text.as_str()), (1, ""));
let a = Attachment {
id: 0,
name: "scan.pdf".into(),
content: Content::Pdf { bytes: Arc::new(vec![1; 10]), path: "/a/scan.pdf".into(), text: Some(Arc::new(blank)) },
thumb: None,
};
assert!(note(&[a], false, false, usize::MAX).contains("no text could be taken from it"));
let broken = Attachment {
id: 0,
name: "x.pdf".into(),
content: Content::Pdf { bytes: Arc::new(vec![1; 10]), path: "/a/x.pdf".into(), text: None },
thumb: None,
};
assert!(note(&[broken], false, false, usize::MAX).contains("its text could not be read"));
let long = "a".repeat(MAX_PDF_CHARS + 5000);
let t = pdf_text(&sample_pdf(&[&long])).unwrap();
assert!(t.cut && t.text.chars().count() == MAX_PDF_CHARS, "{} characters", t.text.chars().count());
}
/// Hostile PDFs never panic: truncated and damaged copies of a real one are read or refused.
#[test]
fn damaged_pdfs_never_panic() {
let good = sample_pdf(&["Wall 2 mm", "Hole 5 mm"]);
assert!(pdf_text(&good).is_some());
assert!(pdf_text(b"").is_none() && pdf_text(b"%PDF-1.7\n").is_none() && pdf_text(b"not a pdf").is_none());
for cut in (0..good.len()).step_by(37) {
let _ = pdf_text(&good[..cut]);
}
let mut seed = 0x9e37_79b9_u32;
for _ in 0..200 {
let mut bad = good.clone();
for _ in 0..4 {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let at = usize::try_from(seed >> 8).unwrap() % bad.len();
bad[at] = (seed & 0xff) as u8;
}
let _ = pdf_text(&bad);
}
}
}
+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),
+14 -1
View File
@@ -330,7 +330,20 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
act(if app.session.visibility.hidden_bodies.contains(name) { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye")
.with(json!({"bodies":[name]})),
],
Target::Body { name } => body_items(app, std::slice::from_ref(name), true),
Target::Body { name } => {
let mut v = body_items(app, std::slice::from_ref(name), true);
// A selected browser body hands every selected body to the mesh dialogs (#12334).
// A row outside the selection still opens them with only the body right-clicked.
let selected = sel_bodies(app);
if selected.len() > 1 && selected.contains(name) {
for item in &mut v {
if item.id == "ui.saveMesh" || item.id == "ui.sendToSlicer" {
item.params = Some(json!({ "bodies": selected }));
}
}
}
v
}
Target::Sketch { id } => sketch_items(app, *id),
Target::Feature { id } => feature_items(app, *id),
Target::Plane { id } => plane_items(app, *id),
+218 -274
View File
@@ -4,13 +4,11 @@
use std::collections::HashMap;
use egui::vec2;
use solidcraft_engine::Session;
use solidcraft_engine::render::{Camera, StandardView};
use crate::SolveApp;
use crate::ui_kit::{self, FileView, RecoveryMessage};
use solidcraft_theme::Tokens;
use crate::start_page;
/// Recent designs kept.
pub const MAX_RECENT: usize = 12;
@@ -622,7 +620,13 @@ enum FileAction {
Shortcuts,
Crashes,
About,
Recovery(solidcraft_engine::recovery::Entry, bool),
/// Recover (true) or discard the recoverable design at this index.
Recovery(usize, bool),
/// The recent design at this index in `HomeState::recent`.
OpenRecent(usize),
ForgetRecent(usize),
ShowInFolder(usize),
Sample(usize),
}
/// File view replaces the workspace while keeping the open design and its history intact.
@@ -672,7 +676,6 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
return;
}
}
let t = Tokens::get();
let samples = solidcraft_engine::sample::samples();
for i in 0..samples.len() {
let key = format!("sample:{i}");
@@ -695,246 +698,174 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
want(&mut app.home, path);
}
}
let mut action = None;
FileView::page(ui, |ui| {
ui.add_enabled_ui(interactive, |ui| {
let full = ui.available_rect_before_wrap();
crate::scenario::publish_rect("home:page", full);
let narrow = full.width() < t.file_nav_width + FileView::tile_min_width() + t.file_content_inset_x * 2.0;
if narrow {
FileView::navigation(ui, |ui| {
ui_kit::dropdown_menu(ui, "home_navigation", app.tr("ui.start_page"), |ui| {
ui.set_min_width(t.file_nav_width);
FileView::navigation(ui, |ui| {
navigation(app, ui, &mut action);
if action.is_some() {
ui.close();
}
});
});
});
content(app, ui, &mut action, true);
} else {
let rail = egui::Rect::from_min_size(full.min, vec2(t.file_nav_width, full.height()));
FileView::rail(ui, rail, |ui| {
egui::ScrollArea::vertical().id_salt("home_navigation").auto_shrink([false, false]).show(ui, |ui| {
navigation(app, ui, &mut action);
});
});
let body = egui::Rect::from_min_max(egui::pos2(rail.right(), full.top()), full.max);
ui.scope_builder(egui::UiBuilder::new().max_rect(body), |ui| content(app, ui, &mut action, false));
}
});
});
if let Some(action) = action {
let page = start_page::Page {
id: "home",
name: app.tr("ui.start_page").into(),
interactive,
back: start_page::Back {
template: app.tr("home.back").into(),
document: app.session.doc.name.as_str().into(),
shortcut: "Esc".into(),
chosen: FileAction::Back,
},
rail: rail(app),
title: match app.home.section {
Section::Start => app.tr_label("Start").into(),
Section::Recovery => app.tr_label("Recover").into(),
Section::Export => app.tr("home.export").into(),
},
hint: app
.tr(match app.home.section {
Section::Start => "home.start_hint",
Section::Recovery => "home.recovery_hint",
Section::Export => "home.export_hint",
})
.into(),
error: app.status.as_ref().filter(|(_, _, error)| *error).map(|(message, _, _)| message.as_str().into()),
body: match app.home.section {
Section::Start => start_page::Body::Start(Box::new(start_body(app))),
Section::Recovery => start_page::Body::Reviews(recovery_body(app)),
Section::Export => start_page::Body::Tiles(export_tiles(app)),
},
};
if let Some(action) = start_page::show(ui, page) {
dispatch(app, ui.ctx(), action);
}
}
fn navigation(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
/// SolidCraft's rail: Start, New, Open, Recover; the design's own entries; the app's.
fn rail(app: &SolveApp) -> Vec<start_page::RailItem<'_, FileAction>> {
let busy = app.file_picker_busy();
let design = !crate::documents::active_is_blank(app);
let response = FileView::back_to(ui, "home:back", app.tr("home.back"), &app.session.doc.name, "Esc");
publish("home:back", &response);
if response.clicked() {
*action = Some(FileAction::Back);
}
let primary = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" };
let shift = if cfg!(target_os = "macos") { "⇧" } else { "Shift+" };
let recover = if app.recoverable.is_empty() {
app.tr_label("Recover").to_string()
let recover: start_page::Label<'_> = if app.recoverable.is_empty() {
app.tr_label("Recover").into()
} else {
format!("{} ({})", app.tr_label("Recover"), app.recoverable.len())
format!("{} ({})", app.tr_label("Recover"), app.recoverable.len()).into()
};
let nav = [
("home:start", app.tr_label("Start"), String::new(), Some(Section::Start), true, FileAction::Section(Section::Start)),
("home:new", app.tr_label("New"), format!("{primary}N"), None, true, FileAction::New("preferred")),
("home:open", app.tr("ui.open"), format!("{primary}O"), None, true, FileAction::Open),
("home:recover", recover.as_str(), String::new(), Some(Section::Recovery), true, FileAction::Section(Section::Recovery)),
("home:save", app.tr("ui.save"), format!("{primary}S"), None, design, FileAction::Save),
("home:save_as", app.tr("ui.save_as"), format!("{primary}{shift}S"), None, design, FileAction::SaveAs),
("home:export", app.tr("home.export"), String::new(), Some(Section::Export), design, FileAction::Section(Section::Export)),
("home:properties", app.tr("home.properties"), String::new(), None, design, FileAction::Properties),
("home:settings", app.tr("home.settings"), format!("{primary},"), None, true, FileAction::Settings),
("home:shortcuts", app.tr("home.shortcuts"), String::new(), None, true, FileAction::Shortcuts),
("home:crashes", app.tr("home.crashes"), String::new(), None, true, FileAction::Crashes),
("home:about", app.tr_label("About SolidCraft"), String::new(), None, true, FileAction::About),
let nav: [(&str, start_page::Label<'_>, String, Option<Section>, bool, FileAction); 12] = [
("home:start", app.tr_label("Start").into(), String::new(), Some(Section::Start), true, FileAction::Section(Section::Start)),
("home:new", app.tr_label("New").into(), format!("{primary}N"), None, true, FileAction::New("preferred")),
("home:open", app.tr("ui.open").into(), format!("{primary}O"), None, true, FileAction::Open),
("home:recover", recover, String::new(), Some(Section::Recovery), true, FileAction::Section(Section::Recovery)),
("home:save", app.tr("ui.save").into(), format!("{primary}S"), None, design, FileAction::Save),
("home:save_as", app.tr("ui.save_as").into(), format!("{primary}{shift}S"), None, design, FileAction::SaveAs),
("home:export", app.tr("home.export").into(), String::new(), Some(Section::Export), design, FileAction::Section(Section::Export)),
("home:properties", app.tr("home.properties").into(), String::new(), None, design, FileAction::Properties),
("home:settings", app.tr("home.settings").into(), format!("{primary},"), None, true, FileAction::Settings),
("home:shortcuts", app.tr("home.shortcuts").into(), String::new(), None, true, FileAction::Shortcuts),
("home:crashes", app.tr("home.crashes").into(), String::new(), None, true, FileAction::Crashes),
("home:about", app.tr_label("About SolidCraft").into(), String::new(), None, true, FileAction::About),
];
for (index, (id, label, key, section, enabled, picked)) in nav.into_iter().enumerate() {
let mut rail = Vec::with_capacity(nav.len() + 2);
for (index, (id, label, shortcut, section, enabled, chosen)) in nav.into_iter().enumerate() {
if index == 4 || index == 8 {
FileView::separator(ui);
}
let response = FileView::nav_row(ui, id, label, &key, section == Some(app.home.section), enabled && !busy);
publish(id, &response);
if response.clicked() {
*action = Some(picked);
rail.push(start_page::RailItem::Separator);
}
rail.push(start_page::RailItem::Entry(start_page::RailEntry {
id: id.into(),
label,
shortcut: shortcut.into(),
selected: section == Some(app.home.section),
enabled: enabled && !busy,
chosen,
}));
}
rail
}
fn publish(id: &str, response: &egui::Response) {
crate::scenario::publish_handle(id, response.rect.center());
crate::scenario::publish_rect(id, response.rect);
}
fn content(app: &mut SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>, narrow: bool) {
let t = Tokens::get();
FileView::content(ui, narrow, |ui| {
egui::ScrollArea::vertical().id_salt("home_content").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (title, hint) = match app.home.section {
Section::Start => (app.tr_label("Start"), app.tr("home.start_hint")),
Section::Recovery => (app.tr_label("Recover"), app.tr("home.recovery_hint")),
Section::Export => (app.tr("home.export"), app.tr("home.export_hint")),
};
FileView::title(ui, title);
ui.add_space(t.space_6);
FileView::subtitle(ui, hint);
ui.add_space(t.file_section_gap);
if let Some((message, _, true)) = &app.status {
ui_kit::text(ui, message, ui_kit::Text::Error("home_status"));
}
match app.home.section {
Section::Start => start_content(app, ui, action),
Section::Recovery => recovery_content(app, ui, action),
Section::Export => export_content(app, ui, action),
}
});
});
}
fn start_content(app: &mut SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
let t = Tokens::get();
if let Some(entry) = app.recoverable.first().cloned() {
let title = solidcraft_ui_text::format("home.recovery_count", &[("count", &app.recoverable.len().to_string())]);
fn start_body(app: &SolveApp) -> start_page::Start<'_, FileAction> {
let banner = app.recoverable.first().map(|entry| {
let filename = entry
.path
.as_deref()
.and_then(|path| std::path::Path::new(path).file_name())
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| entry.name.clone());
let metadata = format!(" · {}", crate::recovery_ui::ago(entry.saved_at));
let (recover, discard) = FileView::recovery_banner(
ui,
"home:recovery",
RecoveryMessage { title: &title, before_filename: " · ", filename: &filename, after_filename: &metadata },
app.tr_label("Recover"),
(app.recoverable.len() == 1).then(|| app.tr_label("Discard")),
);
publish("home:recovery:recover", &recover);
if recover.clicked() {
*action =
Some(if app.recoverable.len() == 1 { FileAction::Recovery(entry.clone(), true) } else { FileAction::Section(Section::Recovery) });
.map(|name| name.to_string_lossy())
.unwrap_or_else(|| entry.name.as_str().into());
let single = app.recoverable.len() == 1;
start_page::Banner {
title: solidcraft_ui_text::format("home.recovery_count", &[("count", &app.recoverable.len().to_string())]).into(),
before_filename: " · ".into(),
filename,
after_filename: format!(" · {}", crate::recovery_ui::ago(entry.saved_at)).into(),
recover: (app.tr_label("Recover").into(), if single { FileAction::Recovery(0, true) } else { FileAction::Section(Section::Recovery) }),
discard: single.then(|| (app.tr_label("Discard").into(), FileAction::Recovery(0, false))),
}
if let Some(discard) = discard {
publish("home:recovery:discard", &discard);
if discard.clicked() {
*action = Some(FileAction::Recovery(entry, false));
}
}
ui.add_space(t.file_section_gap);
}
});
let shortcut = if cfg!(target_os = "macos") { "⌘O" } else { "Ctrl+O" };
let open_hint = solidcraft_ui_text::format("home.open_hint", &[("shortcut", shortcut)]);
let tiles = [
("home:new_mm", "new", app.tr("home.new_mm"), app.tr("home.new_mm_hint"), true),
("home:new_inches", "new", app.tr("home.new_inches"), app.tr("home.new_inches_hint"), false),
("home:open_tile", "open", app.tr("home.open"), open_hint.as_str(), false),
("home:import", "import", app.tr("home.import"), app.tr("home.import_hint"), false),
];
FileView::section(ui, app.tr_label("New"), t.space_12, |ui| {
FileView::grid(ui, "home_new", FileView::tile_min_width(), tiles.len(), |ui, index, width| {
let Some((id, icon, title, hint, primary)) = tiles.get(index) else { return };
let response = FileView::action_tile(ui, id, width, icon, title, hint, *primary);
publish(id, &response);
if response.clicked() {
*action = Some(match index {
0 => FileAction::New("mm"),
1 => FileAction::New("in"),
2 => FileAction::Open,
_ => FileAction::Import,
});
}
});
});
ui.add_space(t.file_section_gap);
FileView::section(ui, app.tr("home.recent"), t.space_10, |ui| {
if app.home.recent.is_empty() {
FileView::detail(ui, app.tr("home.recent_empty"));
}
if !app.home.recent.is_empty() {
FileView::recent_list(ui, |ui| {
for (index, path) in app.home.recent.clone().iter().enumerate() {
// Keep the extension so plate.step and plate.stl are not both "plate" (#9081).
let name = std::path::Path::new(path).file_name().map(|name| name.to_string_lossy()).unwrap_or_else(|| path.as_str().into());
let missing = !available(app, path);
let when = if app.session.path.as_ref() == Some(path) { app.tr("home.open_now").to_string() } else { recent_time(app, path) };
let shown_path = if missing {
format!("{} · {}", app.tr("home.missing"), friendly_path(path))
} else if path.starts_with("/disk/") {
app.tr_label("On your computer").to_string()
} else if path.starts_with("/opfs/") {
app.tr("home.in_this_browser").to_string()
} else {
friendly_path(path)
};
let kind = recent_kind(app, path);
let response = FileView::recent_row(
ui,
&format!("home:recent:{index}"),
ui_kit::RecentRow {
title: &name,
path: &shown_path,
when: &when,
workspace: &kind,
thumbnail: texture(app.home.thumbs.get(path).and_then(Option::as_ref)),
// A design whose picture could not be made shows a dimmed placeholder,
// distinct from the one shown while a picture is still loading (#4655).
thumbnail_failed: app.home.thumb_failed(path),
},
);
publish(&format!("home:recent:{index}"), &response);
ui_kit::Menu::context(&response, |ui| {
if crate::context_menu::button(ui, app.tr_label("Remove from list"), "delete").clicked() {
app.home.forget(path);
ui.close();
}
if !cfg!(target_arch = "wasm32") && crate::context_menu::button(ui, app.tr_label("Show in folder"), "folder").clicked() {
if let Some(show) = &app.services.show_in_folder {
show(path);
}
ui.close();
}
});
if response.clicked() {
open_recent(app, path);
}
}
});
}
});
ui.add_space(t.file_section_gap);
FileView::section(ui, app.tr("home.samples"), t.space_10, |ui| {
let samples: Vec<_> = solidcraft_engine::sample::samples()
.iter()
.enumerate()
.map(|(index, sample)| {
let hint = if app.home.wanted == Some(index) { app.tr("home.opening") } else { app.tr_label(sample.about) };
ui_kit::SampleCard {
title: app.tr_label(sample.name),
description: hint,
icon: "box",
thumbnail: texture(app.home.thumbs.get(&format!("sample:{index}")).and_then(Option::as_ref)),
}
})
.collect();
for (index, response) in FileView::sample_grid(ui, "home:sample", &samples).into_iter().enumerate() {
publish(&format!("home:sample:{index}"), &response);
if response.clicked() {
open_sample(app, index);
}
}
});
("home:new_mm", "new", app.tr("home.new_mm"), start_page::Label::from(app.tr("home.new_mm_hint")), true, FileAction::New("mm")),
("home:new_inches", "new", app.tr("home.new_inches"), app.tr("home.new_inches_hint").into(), false, FileAction::New("in")),
("home:open_tile", "open", app.tr("home.open"), open_hint.into(), false, FileAction::Open),
("home:import", "import", app.tr("home.import"), app.tr("home.import_hint").into(), false, FileAction::Import),
]
.into_iter()
.map(|(id, icon, title, hint, primary, chosen)| start_page::Tile {
id: id.into(),
icon: start_page::Icon::Kit(icon),
title: title.into(),
hint,
primary,
chosen,
})
.collect();
let recent = app.home.recent.iter().enumerate().map(|(index, path)| recent_entry(app, index, path)).collect();
let samples = solidcraft_engine::sample::samples()
.iter()
.enumerate()
.map(|(index, sample)| start_page::Sample {
title: app.tr_label(sample.name).into(),
description: (if app.home.wanted == Some(index) { app.tr("home.opening") } else { app.tr_label(sample.about) }).into(),
icon: start_page::Icon::Kit("box"),
thumbnail: texture(app.home.thumbs.get(&format!("sample:{index}")).and_then(Option::as_ref)),
chosen: FileAction::Sample(index),
})
.collect();
start_page::Start {
banner,
actions_title: app.tr_label("New").into(),
actions: start_page::Tiles { grid: "home_new", tiles },
recent_title: app.tr("home.recent").into(),
recent_empty: app.tr("home.recent_empty").into(),
recent,
samples_title: app.tr("home.samples").into(),
samples,
}
}
fn recent_entry<'a>(app: &'a SolveApp, index: usize, path: &'a str) -> start_page::Recent<'a, FileAction> {
// Keep the extension so plate.step and plate.stl are not both "plate" (#9081).
let title = std::path::Path::new(path).file_name().map_or_else(|| path.into(), |name| name.to_string_lossy());
let missing = !available(app, path);
let when = if app.session.path.as_deref() == Some(path) { app.tr("home.open_now").to_string() } else { recent_time(app, path) };
let shown_path: start_page::Label<'a> = if missing {
format!("{} · {}", app.tr("home.missing"), friendly_path(path)).into()
} else if path.starts_with("/disk/") {
app.tr_label("On your computer").into()
} else if path.starts_with("/opfs/") {
app.tr("home.in_this_browser").into()
} else {
friendly_path(path).into()
};
let mut menu =
vec![start_page::MenuEntry { label: app.tr_label("Remove from list").into(), icon: "delete", chosen: FileAction::ForgetRecent(index) }];
if !cfg!(target_arch = "wasm32") {
menu.push(start_page::MenuEntry { label: app.tr_label("Show in folder").into(), icon: "folder", chosen: FileAction::ShowInFolder(index) });
}
start_page::Recent {
title,
path: shown_path,
when: when.into(),
kind: recent_kind(app, path).into(),
thumbnail: texture(app.home.thumbs.get(path).and_then(Option::as_ref)),
// A design whose picture could not be made shows a dimmed placeholder, distinct from the
// one shown while a picture is still loading (#4655).
thumbnail_failed: app.home.thumb_failed(path),
chosen: FileAction::OpenRecent(index),
menu,
}
}
fn texture(texture: Option<&egui::TextureHandle>) -> Option<(egui::TextureId, egui::Vec2)> {
@@ -1007,62 +938,55 @@ fn recent_time_at(app: &SolveApp, time: u64, now: u64) -> String {
solidcraft_ui_text::format(key, &[("count", &count.to_string())])
}
fn recovery_content(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
if app.recoverable.is_empty() {
ui_kit::text(ui, app.tr("home.recovery_empty"), ui_kit::Text::Inline);
}
for (index, entry) in app.recoverable.iter().take(20).enumerate() {
let detail = solidcraft_ui_text::format(
"recovery.detail",
&[
("file", entry.path.as_deref().unwrap_or(app.tr("recovery.never_saved"))),
("features", &entry.features.to_string()),
("when", &crate::recovery_ui::ago(entry.saved_at)),
],
);
let (recover, discard) =
FileView::recovery_review(ui, &format!("home:recovery:{index}"), &entry.name, &detail, app.tr_label("Recover"), app.tr_label("Discard"));
for (id, response, recover) in [("recover", Some(recover), true), ("discard", discard, false)] {
let Some(response) = response else { continue };
publish(&format!("home:recovery:{index}:{id}"), &response);
if response.clicked() {
*action = Some(FileAction::Recovery(entry.clone(), recover));
}
}
ui.add_space(Tokens::get().space_12);
}
fn recovery_body(app: &SolveApp) -> start_page::Reviews<'_, FileAction> {
let items = app
.recoverable
.iter()
.take(20)
.enumerate()
.map(|(index, entry)| start_page::Review {
title: entry.name.as_str().into(),
detail: solidcraft_ui_text::format(
"recovery.detail",
&[
("file", entry.path.as_deref().unwrap_or(app.tr("recovery.never_saved"))),
("features", &entry.features.to_string()),
("when", &crate::recovery_ui::ago(entry.saved_at)),
],
)
.into(),
recover: (app.tr_label("Recover").into(), FileAction::Recovery(index, true)),
discard: (app.tr_label("Discard").into(), FileAction::Recovery(index, false)),
})
.collect();
start_page::Reviews { empty: app.tr("home.recovery_empty").into(), items }
}
fn export_content(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
fn export_tiles(app: &SolveApp) -> start_page::Tiles<'_, FileAction> {
let formats = [
("step", "STEP", Some("home.export_step_hint")),
("mesh", "Mesh", Some("home.export_mesh_hint")),
("view_dxf", "DXF", None),
("obj", "OBJ", None),
("urdf", "URDF", None),
("json", "JSON", Some("home.export_json_hint")),
("step", "STEP", Some("home.export_step_hint"), FileAction::Export("step")),
("mesh", "Mesh", Some("home.export_mesh_hint"), FileAction::Mesh),
("view_dxf", "DXF", None, FileAction::ViewDxf),
("obj", "OBJ", None, FileAction::Export("obj")),
("urdf", "URDF", None, FileAction::Export("urdf")),
("json", "JSON", Some("home.export_json_hint"), FileAction::Export("json")),
];
FileView::grid(ui, "home_exports", FileView::tile_min_width(), formats.len(), |ui, index, width| {
let Some((id, format, hint)) = formats.get(index) else { return };
let label = if *id == "view_dxf" {
app.tr_label("Export Current View as DXF…").to_string()
} else {
format!("{} {}…", app.tr("home.export"), app.tr_label(format))
};
let subtitle = hint.map(|key| app.tr(key)).unwrap_or(format);
let response = FileView::action_tile(ui, &format!("home:export:{id}"), width, "export", &label, subtitle, false);
publish(&format!("home:export:{id}"), &response);
if response.clicked() {
*action = Some(match *id {
"step" => FileAction::Export("step"),
"mesh" => FileAction::Mesh,
"view_dxf" => FileAction::ViewDxf,
"obj" => FileAction::Export("obj"),
"urdf" => FileAction::Export("urdf"),
_ => FileAction::Export("json"),
});
}
});
let tiles = formats
.into_iter()
.map(|(id, format, hint, chosen)| start_page::Tile {
id: format!("home:export:{id}").into(),
icon: start_page::Icon::Kit("export"),
title: if id == "view_dxf" {
app.tr_label("Export Current View as DXF…").into()
} else {
format!("{} {}…", app.tr("home.export"), app.tr_label(format)).into()
},
hint: hint.map_or(format, |key| app.tr(key)).into(),
primary: false,
chosen,
})
.collect();
start_page::Tiles { grid: "home_exports", tiles }
}
fn dispatch(app: &mut SolveApp, ctx: &egui::Context, action: FileAction) {
@@ -1126,7 +1050,27 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, action: FileAction) {
}
FileAction::Crashes => crate::help::run(app, ctx, "crashes"),
FileAction::About => crate::help::run(app, ctx, "about"),
FileAction::Recovery(entry, recover) => crate::recovery_ui::apply(app, entry, recover),
FileAction::Recovery(index, recover) => {
if let Some(entry) = app.recoverable.get(index).cloned() {
crate::recovery_ui::apply(app, entry, recover);
}
}
FileAction::OpenRecent(index) => {
if let Some(path) = app.home.recent.get(index).cloned() {
open_recent(app, &path);
}
}
FileAction::ForgetRecent(index) => {
if let Some(path) = app.home.recent.get(index).cloned() {
app.home.forget(&path);
}
}
FileAction::ShowInFolder(index) => {
if let (Some(show), Some(path)) = (&app.services.show_in_folder, app.home.recent.get(index)) {
show(path);
}
}
FileAction::Sample(index) => open_sample(app, index),
}
}
+37 -1
View File
@@ -17,7 +17,6 @@ pub(crate) const DRAG_ZOOM: &str = "view.tool.zoom";
/// Keys the application handles before any command shortcut.
pub const RESERVED: &[&str] = &[
"Escape",
"S",
"V",
"F2",
"F6",
@@ -1884,6 +1883,43 @@ mod tests {
h.frames(1);
assert!(h.app.ui.palette_open && h.app.dialog.is_none(), "typing in a field never repeats");
}
/// Command search is an ordinary command (#12190): S opens it out of the box, Preferences ›
/// Shortcuts can move it, and S is then free for another command.
#[test]
fn command_search_can_move_off_s_and_s_can_start_another_command() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.frames(2);
assert_eq!(effective(&h.app, "ui.palette").as_deref(), Some("S"));
h.step(&json!({"key": "S"})).unwrap();
assert!(h.app.ui.palette_open, "S opens command search by default");
h.call("ui.palette", json!({"open": false}));
h.frames(2);
bind(&mut h.app, "ui.palette", "Shift+J", true).unwrap();
h.step(&json!({"key": "S"})).unwrap();
assert!(!h.app.ui.palette_open, "S no longer opens command search");
h.step(&json!({"key": "J", "shift": true})).unwrap();
assert!(h.app.ui.palette_open, "its new key does");
h.call("ui.palette", json!({"open": false}));
h.frames(2);
// The closed field shows the key command search has now, and no key once it has none.
let width = || crate::scenario::rect_of("ribbon:search").map(|r| r.width());
let shown = display_for(&h.app, "ui.palette");
assert_eq!(shown.as_deref(), Some("Shift+J"));
assert_eq!(width(), Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, shown.as_deref())));
assert!(width() > Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S"))), "not the old S");
bind(&mut h.app, "ui.palette", "", false).unwrap();
h.frames(2);
assert_eq!(width(), Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, None)));
bind(&mut h.app, "ui.palette", "Shift+J", true).unwrap();
h.frames(2);
bind(&mut h.app, "solid.box", "S", true).unwrap();
h.step(&json!({"key": "S"})).unwrap();
h.frames(1);
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.command()), Some("solid.box"), "S starts the command given it");
assert!(!h.app.ui.palette_open);
}
}
#[cfg(test)]
+3
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;
@@ -292,6 +293,7 @@ pub mod space_mouse_calibration;
pub mod space_mouse_model;
pub mod space_mouse_offer;
pub mod stand_up;
pub mod start_page;
mod status_bar;
#[cfg(test)]
mod sweep_dialog_tests;
@@ -318,6 +320,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;
+26
View File
@@ -1821,6 +1821,32 @@ fn appearance_browser_selection_reaches_the_editor_without_other_bodies() {
}
}
/// #12334: Save As Mesh and Send to Slicer on a selected browser row open their dialog with every
/// selected body; a row outside the selection, or a lone selected one, opens it with that row only.
#[test]
fn mesh_export_from_a_selected_browser_body_takes_the_selected_bodies() {
let cases = [
("One", vec!["One", "Two"], vec!["One", "Two"]),
("Two", vec!["One", "Two"], vec!["One", "Two"]),
("Other", vec!["One", "Two"], vec!["Other"]),
("One", vec!["One"], vec!["One"]),
];
for label in ["Send to Slicer", "Save As Mesh"] {
for (clicked, selected, expected) in &cases {
let mut app = appearance_selection_app();
// Send to Slicer needs a way to start a program.
app.services.pick_program = Some(Box::new(|| Box::pin(async { None })));
let items: Vec<_> = selected.iter().map(|name| json!({"type":"body","name":name})).collect();
app.run("select.set", json!({ "items": items })).unwrap();
let item = find(&context_menu::items(&app, &Target::Body { name: (*clicked).into() }), label);
context_menu::run_item(&mut app, &item, pos2(0.0, 0.0));
let dialog = app.dialog.as_ref().unwrap_or_else(|| panic!("{label} opens its dialog"));
let bodies: Vec<_> = expected.iter().map(|name| solidcraft_engine::Sel::Body { name: (*name).into() }).collect();
assert_eq!(dialog.inputs[0].items, bodies, "{label}: clicked {clicked}, selected {selected:?}");
}
}
}
#[test]
fn appearance_parent_click_opens_editor_after_hovering_quick_choices() {
use crate::scenario::{Harness, rect_of};
+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
+56
View File
@@ -7326,6 +7326,62 @@ mod tests {
}
}
/// #11445: the theme row opens on the theme in use each time Preferences or its page shows it,
/// and the arrows still move it freely while it stays in view.
#[test]
fn theme_row_opens_on_the_theme_in_use_each_time_it_comes_into_view() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.resize", json!({"width":1600,"height":1100}));
h.call("ui.prefs", json!({"theme":"dark","language":"en","open":"Appearance"}));
h.frames(4);
let count = theme_choices(&h.app).len();
let page_back = |h: &mut crate::scenario::Harness| {
for _ in 0..=count {
if crate::scenario::rect_of("prefs:theme_previous").is_none() {
return;
}
h.step(&json!({"click":{"handle":"prefs:theme_previous"}})).unwrap();
h.frames(3);
}
panic!("the previous arrow never reached the start: the row is held in place");
};
page_back(&mut h);
// A theme the row does not show at its start.
let far =
theme_choices(&h.app).into_iter().map(|(id, _)| id).find(|id| crate::scenario::rect_of(&format!("prefs:theme:{id}")).is_none()).unwrap();
let shows = |id: &str| {
let viewport = crate::scenario::rect_of("prefs:theme_viewport").unwrap();
crate::scenario::rect_of(&format!("prefs:theme:{id}")).is_some_and(|rect| viewport.expand(1.0).contains_rect(rect))
};
h.call("ui.prefs", json!({"theme":far}));
h.frames(4);
assert!(shows(&far), "a theme chosen elsewhere comes into view: {far}");
page_back(&mut h);
assert!(!shows(&far), "the arrows move the row away from the theme in use");
h.call("ui.prefs", json!({"button":"ok"}));
h.frames(3);
h.call("ui.prefs", json!({"open":"Appearance"}));
h.frames(4);
assert!(shows(&far), "reopening Preferences shows the theme in use: {far}");
page_back(&mut h);
h.frames(4);
assert!(!shows(&far), "the row stays where the arrows leave it");
h.call("ui.prefs", json!({"open":"General"}));
h.frames(3);
h.call("ui.prefs", json!({"open":"Appearance"}));
h.frames(4);
assert!(shows(&far), "coming back to the page shows the theme in use: {far}");
page_back(&mut h);
h.app.prefs_window.filter = "Onyx".to_owned();
h.frames(5);
h.app.prefs_window.filter.clear();
h.frames(5);
assert!(shows(&far), "clearing a search shows the theme in use: {far}");
assert_eq!(h.app.preferences.theme, far, "showing a theme never chooses one");
h.call("ui.prefs", json!({"button":"cancel"}));
}
#[test]
fn appearance_controls_edit_real_fields_and_cancel_restores_them() {
let mut h = crate::scenario::Harness::new();
+11 -1
View File
@@ -304,7 +304,17 @@ fn row_contents(app: &mut SolveApp, ui: &mut egui::Ui) {
let selected_index = visible.iter().position(|e| e.id == reveal);
let identity = format!("{reveal}:{:?}:{:?}:{}:{}", state.tone, state.high, state.imported, state.grid);
let reveal_key = ui.make_persistent_id("theme_picker_reveal");
let changed = ui.data(|d| d.get_temp::<String>(reveal_key)).as_ref() != Some(&identity);
// The theme in use also comes into view each time the picker does (#11445): Preferences
// opens, or its page or a cleared search shows it again. Only then: the arrows, the wheel and
// a drag move the row freely while it stays in view.
let shown_key = ui.make_persistent_id("theme_picker_shown");
let pass = ui.ctx().cumulative_pass_nr();
let returning = ui.data_mut(|d| {
let returning = d.get_temp::<u64>(shown_key).is_none_or(|last| last.saturating_add(1) < pass);
d.insert_temp(shown_key, pass);
returning
});
let changed = returning || ui.data(|d| d.get_temp::<String>(reveal_key)).as_ref() != Some(&identity);
let mut chosen = None;
let mut card = |ui: &mut egui::Ui, index: usize, width: f32| {
let Some(e) = visible.get(index) else { return };
+3 -1
View File
@@ -455,6 +455,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui_kit::scope(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let invalid = typed(&query).is_some_and(|(_, result)| result.is_err());
let key = crate::keymap::display_for(app, "ui.palette");
let field = ui_kit::CommandSearch::field(
ui,
&mut query,
@@ -466,6 +467,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
progress,
radius: ui_kit::CommandSearch::field_radius(progress, ui_kit::Container::new(ctx, ui_kit::Surface::Chrome).floating()),
},
key.as_deref(),
MAX_QUERY,
);
if !open && progress == 0.0 {
@@ -1096,7 +1098,7 @@ mod tests {
let toolbar = crate::scenario::rect_of("toolbar").unwrap();
assert_eq!(crate::toolbar::TAB_ROW_HEIGHT, 30.0);
assert_eq!(crate::toolbar::RIBBON_HEIGHT, 117.0);
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx), 22.0));
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), 22.0));
assert_eq!(closed.right(), toolbar.right() - 4.0);
assert_eq!(closed.top(), toolbar.top() + 4.0);
assert_eq!(closed.center().y, toolbar.top() + 15.0);
+560
View File
@@ -0,0 +1,560 @@
//! The start page as a component: a rail of entries beside a titled body (action tiles, the
//! recent files and the samples, or a section of the host's tiles or reviews). The host gives
//! every label, icon, thumbnail and what each entry stands for (`A`); [`show`] draws the page and
//! returns the entry chosen this frame, which the host runs. Nothing here knows the app.
//!
//! Tiles and samples take a host [`Icon`] (a kit icon name, a texture or the host's painter);
//! menu entries take a kit icon name. Thumbnails are textures the host made.
use std::borrow::Cow;
use egui::vec2;
pub use crate::ui_kit::Icon;
use crate::ui_kit::{self, FileView, RecoveryMessage};
use solidcraft_theme::Tokens;
/// Text the host gives: borrowed when it has it, owned when it formatted it this frame.
pub type Label<'a> = Cow<'a, str>;
/// The whole page. `id` names it (`{id}:page`, `{id}:back`, `{id}:recent:N`, `{id}:sample:N`…).
pub struct Page<'a, A> {
pub id: &'a str,
/// The narrow layout's menu title, standing for the rail.
pub name: Label<'a>,
/// Whether the page takes input (not while a file picker or a load is up).
pub interactive: bool,
pub back: Back<'a, A>,
pub rail: Vec<RailItem<'a, A>>,
pub title: Label<'a>,
pub hint: Label<'a>,
/// An error shown under the hint.
pub error: Option<Label<'a>>,
pub body: Body<'a, A>,
}
/// The rail's first row, back to the open document.
pub struct Back<'a, A> {
/// The label, with `{name}` for the document's name.
pub template: Label<'a>,
pub document: Label<'a>,
pub shortcut: Label<'a>,
pub chosen: A,
}
pub enum RailItem<'a, A> {
Entry(RailEntry<'a, A>),
Separator,
}
pub struct RailEntry<'a, A> {
pub id: Label<'a>,
pub label: Label<'a>,
/// The key hint drawn at the row's end (empty for none).
pub shortcut: Label<'a>,
pub selected: bool,
pub enabled: bool,
pub chosen: A,
}
pub enum Body<'a, A> {
/// Boxed: the start section is far larger than the other bodies.
Start(Box<Start<'a, A>>),
Tiles(Tiles<'a, A>),
Reviews(Reviews<'a, A>),
}
/// The start section: an optional recovery banner, the action tiles, the recent files and the
/// samples.
pub struct Start<'a, A> {
pub banner: Option<Banner<'a, A>>,
pub actions_title: Label<'a>,
pub actions: Tiles<'a, A>,
pub recent_title: Label<'a>,
/// Shown when there are no recent files.
pub recent_empty: Label<'a>,
pub recent: Vec<Recent<'a, A>>,
pub samples_title: Label<'a>,
pub samples: Vec<Sample<'a, A>>,
}
/// A grid of action tiles; `grid` keeps its widgets' state apart from other grids.
pub struct Tiles<'a, A> {
pub grid: &'a str,
pub tiles: Vec<Tile<'a, A>>,
}
pub struct Tile<'a, A> {
pub id: Label<'a>,
pub icon: Icon<'a>,
pub title: Label<'a>,
pub hint: Label<'a>,
/// Drawn as the page's main action.
pub primary: bool,
pub chosen: A,
}
pub struct Banner<'a, A> {
pub title: Label<'a>,
pub before_filename: Label<'a>,
pub filename: Label<'a>,
pub after_filename: Label<'a>,
pub recover: (Label<'a>, A),
pub discard: Option<(Label<'a>, A)>,
}
pub struct Recent<'a, A> {
pub title: Label<'a>,
pub path: Label<'a>,
pub when: Label<'a>,
pub kind: Label<'a>,
pub thumbnail: Option<(egui::TextureId, egui::Vec2)>,
/// No picture could be made: a dimmed placeholder rather than a loading one.
pub thumbnail_failed: bool,
pub chosen: A,
/// The row's context menu.
pub menu: Vec<MenuEntry<'a, A>>,
}
pub struct MenuEntry<'a, A> {
pub label: Label<'a>,
/// A kit icon name: the menu row is a `ui_kit::MenuItem`, which takes a host `Icon` with #13379.
pub icon: &'a str,
pub chosen: A,
}
pub struct Sample<'a, A> {
pub title: Label<'a>,
pub description: Label<'a>,
pub icon: Icon<'a>,
pub thumbnail: Option<(egui::TextureId, egui::Vec2)>,
pub chosen: A,
}
/// A list of entries to review (the recoverable designs), each with a keep and a drop action.
pub struct Reviews<'a, A> {
/// Shown when the list is empty.
pub empty: Label<'a>,
pub items: Vec<Review<'a, A>>,
}
pub struct Review<'a, A> {
pub title: Label<'a>,
pub detail: Label<'a>,
pub recover: (Label<'a>, A),
pub discard: (Label<'a>, A),
}
/// Draw the page; the entry chosen this frame, if any.
pub fn show<A>(ui: &mut egui::Ui, page: Page<'_, A>) -> Option<A> {
let t = Tokens::get();
let Page { id, name, interactive, back, rail, title, hint, error, body } = page;
let mut chosen = None;
let mut rail = Some((back, rail));
let mut body = Some(body);
FileView::page(ui, |ui| {
ui.add_enabled_ui(interactive, |ui| {
let full = ui.available_rect_before_wrap();
solidcraft_ui_registry::publish_rect(&format!("{id}:page"), full);
let narrow = full.width() < t.file_nav_width + FileView::tile_min_width() + t.file_content_inset_x * 2.0;
let navigation_id = format!("{id}_navigation");
if narrow {
FileView::navigation(ui, |ui| {
ui_kit::dropdown_menu(ui, navigation_id.as_str(), &name, |ui| {
ui.set_min_width(t.file_nav_width);
FileView::navigation(ui, |ui| {
if let Some((back, rail)) = rail.take() {
navigation(ui, id, back, rail, &mut chosen);
}
if chosen.is_some() {
ui.close();
}
});
});
});
if let Some(body) = body.take() {
content(ui, id, (title.as_ref(), hint.as_ref(), error.as_deref()), body, true, &mut chosen);
}
} else {
let rail_rect = egui::Rect::from_min_size(full.min, vec2(t.file_nav_width, full.height()));
FileView::rail(ui, rail_rect, |ui| {
egui::ScrollArea::vertical().id_salt(navigation_id.as_str()).auto_shrink([false, false]).show(ui, |ui| {
if let Some((back, rail)) = rail.take() {
navigation(ui, id, back, rail, &mut chosen);
}
});
});
let body_rect = egui::Rect::from_min_max(egui::pos2(rail_rect.right(), full.top()), full.max);
ui.scope_builder(egui::UiBuilder::new().max_rect(body_rect), |ui| {
if let Some(body) = body.take() {
content(ui, id, (title.as_ref(), hint.as_ref(), error.as_deref()), body, false, &mut chosen);
}
});
}
});
});
chosen
}
fn navigation<A>(ui: &mut egui::Ui, id: &str, back: Back<'_, A>, rail: Vec<RailItem<'_, A>>, chosen: &mut Option<A>) {
let back_id = format!("{id}:back");
let response = FileView::back_to(ui, &back_id, &back.template, &back.document, &back.shortcut);
publish(&back_id, &response);
if response.clicked() {
*chosen = Some(back.chosen);
}
for item in rail {
match item {
RailItem::Separator => FileView::separator(ui),
RailItem::Entry(entry) => {
let response = FileView::nav_row(ui, &entry.id, &entry.label, &entry.shortcut, entry.selected, entry.enabled);
publish(&entry.id, &response);
if response.clicked() {
*chosen = Some(entry.chosen);
}
}
}
}
}
fn publish(id: &str, response: &egui::Response) {
solidcraft_ui_registry::publish_handle(id, response.rect.center());
solidcraft_ui_registry::publish_rect(id, response.rect);
}
fn content<A>(ui: &mut egui::Ui, id: &str, heading: (&str, &str, Option<&str>), body: Body<'_, A>, narrow: bool, chosen: &mut Option<A>) {
let t = Tokens::get();
let (title, hint, error) = heading;
let mut body = Some(body);
FileView::content(ui, narrow, |ui| {
egui::ScrollArea::vertical().id_salt(format!("{id}_content")).auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
FileView::title(ui, title);
ui.add_space(t.space_6);
FileView::subtitle(ui, hint);
ui.add_space(t.file_section_gap);
if let Some(message) = error {
ui_kit::text(ui, message, ui_kit::Text::Error(&format!("{id}_status")));
}
match body.take() {
Some(Body::Start(start)) => start_content(ui, id, *start, chosen),
Some(Body::Tiles(tiles)) => tile_grid(ui, tiles, chosen),
Some(Body::Reviews(reviews)) => review_list(ui, id, reviews, chosen),
None => {}
}
});
});
}
fn tile_grid<A>(ui: &mut egui::Ui, tiles: Tiles<'_, A>, chosen: &mut Option<A>) {
let Tiles { grid, tiles } = tiles;
let mut tiles: Vec<Option<Tile<'_, A>>> = tiles.into_iter().map(Some).collect();
FileView::grid(ui, grid, FileView::tile_min_width(), tiles.len(), |ui, index, width| {
let Some(tile) = tiles.get_mut(index).and_then(Option::take) else { return };
let response = FileView::action_tile(ui, &tile.id, width, tile.icon, &tile.title, &tile.hint, tile.primary);
publish(&tile.id, &response);
if response.clicked() {
*chosen = Some(tile.chosen);
}
});
}
fn start_content<A>(ui: &mut egui::Ui, id: &str, start: Start<'_, A>, chosen: &mut Option<A>) {
let t = Tokens::get();
let Start { banner, actions_title, actions, recent_title, recent_empty, recent, samples_title, samples } = start;
if let Some(banner) = banner {
let banner_id = format!("{id}:recovery");
let message = RecoveryMessage {
title: &banner.title,
before_filename: &banner.before_filename,
filename: &banner.filename,
after_filename: &banner.after_filename,
};
let (recover_label, recover_chosen) = banner.recover;
let (discard_label, discard_chosen) = banner.discard.map_or((None, None), |(label, chosen)| (Some(label), Some(chosen)));
let (recover, discard) = FileView::recovery_banner(ui, &banner_id, message, &recover_label, discard_label.as_deref());
publish(&format!("{banner_id}:recover"), &recover);
if recover.clicked() {
*chosen = Some(recover_chosen);
}
if let (Some(discard), Some(discard_chosen)) = (discard, discard_chosen) {
publish(&format!("{banner_id}:discard"), &discard);
if discard.clicked() {
*chosen = Some(discard_chosen);
}
}
ui.add_space(t.file_section_gap);
}
FileView::section(ui, &actions_title, t.space_12, |ui| tile_grid(ui, actions, chosen));
ui.add_space(t.file_section_gap);
FileView::section(ui, &recent_title, t.space_10, |ui| {
if recent.is_empty() {
FileView::detail(ui, &recent_empty);
return;
}
FileView::recent_list(ui, |ui| {
for (index, row) in recent.into_iter().enumerate() {
let row_id = format!("{id}:recent:{index}");
let response = FileView::recent_row(
ui,
&row_id,
ui_kit::RecentRow {
title: &row.title,
path: &row.path,
when: &row.when,
workspace: &row.kind,
thumbnail: row.thumbnail,
thumbnail_failed: row.thumbnail_failed,
},
);
publish(&row_id, &response);
let mut menu = row.menu.into_iter().map(Some).collect::<Vec<_>>();
ui_kit::Menu::context(&response, |ui| {
for entry in &mut menu {
let picked =
entry.as_ref().is_some_and(|entry| ui_kit::MenuItem::new(&entry.label, &entry.label, entry.icon).show(ui).clicked());
if picked && let Some(entry) = entry.take() {
*chosen = Some(entry.chosen);
ui.close();
}
}
});
if response.clicked() {
*chosen = Some(row.chosen);
}
}
});
});
ui.add_space(t.file_section_gap);
FileView::section(ui, &samples_title, t.space_10, |ui| {
let (shown, picks): (Vec<_>, Vec<_>) =
samples.into_iter().map(|sample| ((sample.title, sample.description, sample.icon, sample.thumbnail), sample.chosen)).unzip();
let cards: Vec<_> = shown
.iter()
.map(|(title, description, icon, thumbnail)| ui_kit::SampleCard { title, description, icon: *icon, thumbnail: *thumbnail })
.collect();
let sample_id = format!("{id}:sample");
let responses = FileView::sample_grid(ui, &sample_id, &cards);
for ((index, response), pick) in responses.into_iter().enumerate().zip(picks) {
publish(&format!("{sample_id}:{index}"), &response);
if response.clicked() {
*chosen = Some(pick);
}
}
});
}
fn review_list<A>(ui: &mut egui::Ui, id: &str, reviews: Reviews<'_, A>, chosen: &mut Option<A>) {
if reviews.items.is_empty() {
ui_kit::text(ui, &reviews.empty, ui_kit::Text::Inline);
}
for (index, review) in reviews.items.into_iter().enumerate() {
let review_id = format!("{id}:recovery:{index}");
let (recover_label, recover_chosen) = review.recover;
let (discard_label, discard_chosen) = review.discard;
let (recover, discard) = FileView::recovery_review(ui, &review_id, &review.title, &review.detail, &recover_label, &discard_label);
publish(&format!("{review_id}:recover"), &recover);
if recover.clicked() {
*chosen = Some(recover_chosen);
}
if let Some(discard) = discard {
publish(&format!("{review_id}:discard"), &discard);
if discard.clicked() {
*chosen = Some(discard_chosen);
}
}
ui.add_space(Tokens::get().space_12);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug, PartialEq)]
enum Picked {
Back,
Rail(&'static str),
Tile(&'static str),
Recent(usize),
Forget(usize),
ShowInFolder(usize),
Sample(usize),
}
/// The texture the second tile's icon is drawn from.
const HOST_ICON: egui::TextureId = egui::TextureId::User(7);
/// A start page of plain data: no app, no design, no engine.
fn page(recent: usize) -> Page<'static, Picked> {
let tile = |id: &'static str, icon, primary| Tile {
id: id.into(),
icon,
title: id.into(),
hint: "a hint".into(),
primary,
chosen: Picked::Tile(id),
};
let entry = |id: &'static str, selected, enabled| RailEntry {
id: id.into(),
label: id.into(),
shortcut: "".into(),
selected,
enabled,
chosen: Picked::Rail(id),
};
Page {
id: "start",
name: "Start page".into(),
interactive: true,
back: Back { template: "Back to {name}".into(), document: "Plate".into(), shortcut: "Esc".into(), chosen: Picked::Back },
rail: vec![
RailItem::Entry(entry("start:home", true, true)),
RailItem::Entry(entry("start:save", false, false)),
RailItem::Separator,
RailItem::Entry(entry("start:about", false, true)),
],
title: "Start".into(),
hint: "Pick something".into(),
error: None,
body: Body::Start(Box::new(Start {
banner: None,
actions_title: "New".into(),
actions: Tiles {
grid: "start_new",
tiles: vec![tile("start:first", Icon::Kit("new"), true), tile("start:second", Icon::Texture(HOST_ICON), false)],
},
recent_title: "Recent".into(),
recent_empty: "Nothing yet".into(),
recent: (0..recent)
.map(|i| Recent {
title: format!("part {i}").into(),
path: "~/part".into(),
when: "Just now".into(),
kind: "Surface".into(),
thumbnail: None,
thumbnail_failed: false,
chosen: Picked::Recent(i),
menu: vec![
MenuEntry { label: "Remove from list".into(), icon: "delete", chosen: Picked::Forget(i) },
MenuEntry { label: "Show in folder".into(), icon: "folder", chosen: Picked::ShowInFolder(i) },
],
})
.collect(),
samples_title: "Samples".into(),
samples: (0..3)
.map(|i| Sample {
title: format!("sample {i}").into(),
description: "a sample".into(),
icon: Icon::Kit("box"),
thumbnail: None,
chosen: Picked::Sample(i),
})
.collect(),
})),
}
}
/// The page drawn frame by frame, with what it chose so far.
struct Drive {
ctx: egui::Context,
screen: egui::Rect,
recent: usize,
chosen: Vec<Picked>,
}
impl Drive {
fn new(size: egui::Vec2, recent: usize) -> Drive {
let mut drive =
Drive { ctx: crate::fonts::test_context(), screen: egui::Rect::from_min_size(egui::Pos2::ZERO, size), recent, chosen: Vec::new() };
drive.frame(Vec::new());
drive
}
fn frame(&mut self, events: Vec<egui::Event>) -> egui::FullOutput {
solidcraft_ui_registry::clear_handles();
let input = egui::RawInput { screen_rect: Some(self.screen), focused: true, events, ..Default::default() };
let recent = self.recent;
let chosen = &mut self.chosen;
crate::test_support::frame(&self.ctx, input, |ui| chosen.extend(show(ui, page(recent))))
}
fn at(name: &str) -> egui::Pos2 {
solidcraft_ui_registry::handle_at(name).unwrap_or_else(|| panic!("{name} was not drawn"))
}
/// Press and release at what `name` published last frame.
fn click_with(&mut self, name: &str, button: egui::PointerButton) {
let pos = Self::at(name);
for pressed in [true, false] {
self.frame(vec![egui::Event::PointerMoved(pos), egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }]);
}
}
fn click(&mut self, name: &str) {
self.click_with(name, egui::PointerButton::Primary);
}
}
fn click(size: egui::Vec2, name: &str, recent: usize) -> Vec<Picked> {
let mut drive = Drive::new(size, recent);
drive.click(name);
drive.chosen
}
#[test]
fn start_page_from_plain_data_returns_the_tile_clicked() {
let wide = egui::vec2(1400.0, 900.0);
assert_eq!(click(wide, "start:second", 0), [Picked::Tile("start:second")]);
assert_eq!(click(wide, "start:first", 0), [Picked::Tile("start:first")]);
}
#[test]
fn start_page_returns_the_rail_entry_recent_and_sample_clicked() {
let wide = egui::vec2(1400.0, 900.0);
assert_eq!(click(wide, "start:back", 2), [Picked::Back]);
assert_eq!(click(wide, "start:about", 2), [Picked::Rail("start:about")]);
assert_eq!(click(wide, "start:recent:1", 2), [Picked::Recent(1)]);
assert_eq!(click(wide, "start:sample:2", 2), [Picked::Sample(2)]);
}
#[test]
fn start_page_disabled_rail_entry_returns_nothing() {
assert_eq!(click(egui::vec2(1400.0, 900.0), "start:save", 0), []);
}
#[test]
fn start_page_recent_context_menu_returns_the_entry_picked() {
for (entry, picked) in [("menu:Remove from list", Picked::Forget(1)), ("menu:Show in folder", Picked::ShowInFolder(1))] {
let mut drive = Drive::new(egui::vec2(1400.0, 900.0), 2);
drive.click_with("start:recent:1", egui::PointerButton::Secondary);
drive.frame(Vec::new());
drive.click(entry);
assert_eq!(drive.chosen, [picked], "{entry}");
}
}
#[test]
fn start_page_tile_draws_the_hosts_texture_icon() {
let mut drive = Drive::new(egui::vec2(1400.0, 900.0), 0);
let output = drive.frame(Vec::new());
let tile = solidcraft_ui_registry::rect_of("start:second").unwrap();
let mut drawn = false;
for clipped in &output.shapes {
crate::test_support::each_shape(&clipped.shape, &mut |shape| {
if let egui::Shape::Mesh(mesh) = shape {
drawn |= mesh.texture_id == HOST_ICON && tile.contains_rect(mesh.calc_bounds());
}
});
}
assert!(drawn, "the second tile draws its icon from the host's texture");
}
#[test]
fn start_page_lays_out_inside_a_narrow_window_and_keeps_its_tiles() {
assert_eq!(click(egui::vec2(420.0, 800.0), "start:first", 0), [Picked::Tile("start:first")]);
let page = solidcraft_ui_registry::rect_of("start:page").unwrap();
assert!(page.contains_rect(solidcraft_ui_registry::rect_of("start:second").unwrap()));
assert!(solidcraft_ui_registry::handle_at("start:back").is_none(), "the rail folds into the page's menu");
}
}
+7 -9
View File
@@ -275,9 +275,9 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32, vers
/// The closed search field in the application bar, ending `right`: whole when the document tabs
/// keep the room Report a bug's label leaves them (`tabs_room`: theirs without the field), else
/// just its magnifier.
fn header_search_rect(ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
fn header_search_rect(app: &SolveApp, ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
let height = 26.0;
let full = crate::ui_kit::CommandSearch::closed_width(ctx);
let full = crate::ui_kit::CommandSearch::closed_width(ctx, crate::keymap::display_for(app, "ui.palette").as_deref());
let whole = bar.width() >= 640.0 && tabs_room - full - HEADER_SEARCH_GAP >= 216.0;
let width = if whole { full } else { crate::ui_kit::CommandSearch::compact_width(height) };
Rect::from_min_size(pos2(right - width, bar.center().y - height / 2.0), vec2(width, height))
@@ -536,7 +536,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
// moves the assistant button and the end of the document tabs left by its width.
let gear_left = report.left() - 68.0;
let search = (search_in_header(app) && design_shown(app))
.then(|| header_search_rect(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
.then(|| header_search_rect(app, ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
let search_shift = search.map_or(0.0, |s| s.width() + HEADER_SEARCH_GAP);
if let Some(search) = search {
// shortcut_box::show draws the one search editor wherever this rectangle is.
@@ -1385,7 +1385,8 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) -> Option<bool>
// The application bar may hold the search field instead ([`HeaderLayout`]): the tabs then
// run to the row's end.
let in_ribbon = !search_in_header(app);
let search_width = if in_ribbon { crate::ui_kit::CommandSearch::closed_width(ui.ctx()) } else { 0.0 };
let search_width =
if in_ribbon { crate::ui_kit::CommandSearch::closed_width(ui.ctx(), crate::keymap::display_for(app, "ui.palette").as_deref()) } else { 0.0 };
// The first tab and the search field sit the same distance from the card's top, left and right
// edges, so their outer corners are concentric with the card's corners.
let floating = Container::for_layout(app.ui.layout, Surface::Panel).floating();
@@ -3742,9 +3743,6 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
// A single Esc with nothing left to cancel keeps the sketch open (#1804); a quick
// second press finishes it (above).
}
Key::S if !mods.shift && !mods.alt => {
app.start("ui.palette");
}
// With a menu open, V runs that menu's Show/Hide on what the menu is for.
Key::V => {
if !crate::context_menu::press_shortcut(app, "V") {
@@ -4474,7 +4472,7 @@ mod header_report_tests {
let search = rect("ribbon:search");
assert!(search.top() >= APP_BAR_HEIGHT, "search is in the ribbon: {search:?}");
assert_eq!(search.height(), TAB_ROW_HEIGHT - 2.0 * RIBBON_TAB_INSET);
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx));
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")));
assert!(crate::scenario::rect_of("titlebar:search").is_none());
let report = rect("titlebar:report");
// A pre-release's longer version takes more room than RIGHT_GROUP and moves the button left.
@@ -4507,7 +4505,7 @@ mod header_report_tests {
let (search, prefs) = (rect("titlebar:search"), rect("titlebar:preferences"));
assert_eq!(search.right(), prefs.left() - HEADER_SEARCH_GAP, "search sits immediately left of Settings");
assert!(search.bottom() <= APP_BAR_HEIGHT && (search.center().y - prefs.center().y).abs() < 0.5);
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx), "the whole field where there is room");
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), "the whole field where there is room");
assert_eq!(rect("ribbon:search"), search, "the one search field is drawn in the bar");
// Report a bug: an icon the size of Settings and Help, still named for assistive tools.
let report = rect("titlebar:report");
+44 -10
View File
@@ -25,11 +25,43 @@ pub(crate) struct RecentRow<'a> {
pub thumbnail_failed: bool,
}
/// A picture the host gives a kit surface: a name in the kit's icon set, a texture the host made
/// (its own app icon, say), or the host's own painter, called with the icon's rect and opacity.
#[derive(Clone, Copy)]
pub enum Icon<'a> {
Kit(&'a str),
Texture(TextureId),
Paint(&'a dyn Fn(&egui::Painter, Rect, f32)),
}
impl<'a> From<&'a str> for Icon<'a> {
fn from(name: &'a str) -> Self {
Icon::Kit(name)
}
}
impl Icon<'_> {
pub(crate) fn paint(&self, painter: &egui::Painter, rect: Rect, opacity: f32) {
match self {
Icon::Kit(name) => crate::icons::paint_accented(painter, rect, name, None, opacity),
Icon::Texture(texture) => {
painter.image(
*texture,
rect,
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
Tokens::get().file_image_tint.gamma_multiply(opacity),
);
}
Icon::Paint(paint) => paint(painter, rect, opacity),
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct SampleCard<'a> {
pub title: &'a str,
pub description: &'a str,
pub icon: &'a str,
pub icon: Icon<'a>,
pub thumbnail: Option<(TextureId, Vec2)>,
}
@@ -245,14 +277,22 @@ impl FileView {
ui.add_space(t.space_10);
}
pub(crate) fn action_tile(ui: &mut Ui, id: &str, width: f32, icon: &str, label: &str, subtitle: &str, primary: bool) -> Response {
pub(crate) fn action_tile<'a>(
ui: &mut Ui,
id: &str,
width: f32,
icon: impl Into<Icon<'a>>,
label: &str,
subtitle: &str,
primary: bool,
) -> Response {
let t = Tokens::get();
let (rect, response) = allocate(ui, id, width, t.file_tile_height, label);
card(ui, rect, &response, primary);
let inner = rect.shrink(t.space_16);
let icon_size = t.file_tile_icon.min(inner.height()).min(inner.width());
let icon_rect = Rect::from_center_size(pos2(inner.left() + icon_size / 2.0, inner.center().y), Vec2::splat(icon_size));
crate::icons::paint_accented(ui.painter(), icon_rect, icon, None, if ui.is_enabled() { 1.0 } else { 0.45 });
icon.into().paint(ui.painter(), icon_rect, if ui.is_enabled() { 1.0 } else { 0.45 });
let text_rect = Rect::from_min_max(pos2((icon_rect.right() + t.space_14).min(inner.right()), inner.top()), inner.max);
let ink = if primary { t.file_primary_ink } else { t.form_ink };
let mut title_job = egui::text::LayoutJob::simple(label.to_owned(), crate::fonts::semibold(t.font_heading), ink, text_rect.width());
@@ -562,13 +602,7 @@ fn sample_card_with_height(ui: &mut Ui, id: &str, width: f32, sample: &SampleCar
let image = Rect::from_min_size(inner.min, vec2(inner.width(), t.file_sample_image_height));
thumbnail(ui, image, sample.thumbnail);
if sample.thumbnail.is_none() {
crate::icons::paint_accented(
ui.painter(),
Rect::from_center_size(image.center(), Vec2::splat(t.file_tile_icon.min(image.width()))),
sample.icon,
None,
1.0,
);
sample.icon.paint(ui.painter(), Rect::from_center_size(image.center(), Vec2::splat(t.file_tile_icon.min(image.width()))), 1.0);
}
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(inner));
let title_top = image.bottom() + t.space_8;
+2 -1
View File
@@ -1120,7 +1120,7 @@ const ELEMENTS: &[Element] = &[
FileView::sample_grid(
ui,
"gallery:file:sample",
&[SampleCard { title: "Sample Plate", description: "Parametric plate with holes and a bored boss", icon: "new", thumbnail: None }],
&[SampleCard { title: "Sample Plate", description: "Parametric plate with holes and a bored boss", icon: "new".into(), thumbnail: None }],
);
}),
element!("file_recovery", |ui, _| {
@@ -1145,6 +1145,7 @@ const ELEMENTS: &[Element] = &[
radius: CommandSearch::field_radius(1.0, true),
progress: 1.0,
},
Some("S"),
4096,
);
ui.add_space(Tokens::get().space_6);
+2 -2
View File
@@ -137,8 +137,8 @@ fn sample_grid_stretches_each_row_without_clipping_long_descriptions() {
|ui| {
ui.set_width(680.0);
cards = FileView::sample_grid(ui, "samples", &[
SampleCard { title: "Plate", description: "Short description", icon: "new", thumbnail: None },
SampleCard { title: "Enclosure", description: "Long descriptive sample text that wraps across several lines while preserving every word through the final marker.", icon: "new", thumbnail: None },
SampleCard { title: "Plate", description: "Short description", icon: "new".into(), thumbnail: None },
SampleCard { title: "Enclosure", description: "Long descriptive sample text that wraps across several lines while preserving every word through the final marker.", icon: "new".into(), thumbnail: None },
]);
},
);
+1
View File
@@ -83,6 +83,7 @@ pub(crate) use disclosure::*;
pub(crate) use drag::*;
pub(crate) use fields::*;
pub use fields::{Expressions, cell_editor, install_expressions, wide_cell_editor};
pub use file_view::Icon;
pub(crate) use file_view::{FileView, RecentRow, RecoveryMessage, SampleCard};
pub(crate) use framing::*;
pub(crate) use history::*;

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