Compare commits

..

21 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
91 changed files with 6807 additions and 631 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. -->
+4
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
+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`.
+1 -1
View File
@@ -448,7 +448,7 @@ 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)
+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:?}");
}
}
+81 -26
View File
@@ -4866,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
@@ -4895,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
@@ -7652,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()));
@@ -7667,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();
@@ -7702,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;
+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())),
}
})
+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]
+6 -3
View File
@@ -15,8 +15,8 @@ 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};
@@ -24,6 +24,7 @@ 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();
@@ -48,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")
+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))
}
+44 -18
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")
@@ -102,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)
@@ -138,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"))
@@ -310,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
@@ -980,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.
@@ -998,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"] {
+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();
+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"));
}
+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}");
}
}
+2 -2
View File
@@ -235,8 +235,8 @@ pub struct ExportResult {
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` also StlAscii,
/// ThreeMfTagged or Design).
/// 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")]
+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);
}
+3
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;
+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);
}
}
+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]
+144 -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)]
@@ -1360,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
@@ -1369,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,
@@ -1416,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`.
-4
View File
@@ -7,10 +7,8 @@ doc.recover
doc.recovery_discard
doc.recovery_list
face.delete
file.insert_mesh
file.insert_step
file.new
file.save_mesh
file.slicers
motion.export_animation
motion.trace
@@ -23,7 +21,6 @@ render.preflight
render.scene
render.start
sheet.export_dxf
solid.align
solid.coil
solid.draft
solid.emboss
@@ -37,7 +34,6 @@ solid.pattern.path
solid.pattern.rectangular
solid.press_pull
solid.replace_face
solid.revolve
solid.shell
solid.thread
surface.blend
+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`.
+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);
}
}
}
+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)]
+1
View File
@@ -293,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;
+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};
+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::*;
+16 -14
View File
@@ -51,20 +51,23 @@ impl CommandSearch {
}
/// Reserve the translated hint at the exact editor font, plus compact controls.
/// The small text allowance covers pixel rounding and the editor's caret.
pub(crate) fn closed_width(ctx: &egui::Context) -> f32 {
/// The small text allowance covers pixel rounding and the editor's caret. `key` is command
/// search's own key (#12190): its cap, or none when the user took it away.
pub(crate) fn closed_width(ctx: &egui::Context, key: Option<&str>) -> f32 {
let t = Tokens::get();
let spacing = FieldSpacing::at(0.0);
let (hint, key) = ctx.fonts_mut(|fonts| {
let (hint, cap) = ctx.fonts_mut(|fonts| {
(
fonts
.layout_no_wrap(solidcraft_ui_text::tr_label("Search commands").to_owned(), FontId::proportional(t.font_button), t.form_dim)
.size()
.x,
fonts.layout_no_wrap("S".to_owned(), FontId::monospace(t.font_small), t.form_dim).size().x,
key.map_or(0.0, |key| {
fonts.layout_no_wrap(key.to_owned(), FontId::monospace(t.font_small), t.form_dim).size().x + spacing.key_gap + spacing.key_padding
}),
)
});
(spacing.left + t.space_16 + spacing.icon_gap + hint + spacing.key_gap + key + spacing.key_padding + spacing.right + t.space_2).ceil()
(spacing.left + t.space_16 + spacing.icon_gap + hint + cap + spacing.right + t.space_2).ceil()
}
/// The closed field as just its magnifier, for a bar short of room: a square button.
@@ -92,6 +95,7 @@ impl CommandSearch {
focus: bool,
open: bool,
geometry: SearchFieldGeometry,
key: Option<&str>,
limit: usize,
) -> Response {
let t = Tokens::get();
@@ -124,7 +128,7 @@ impl CommandSearch {
bevel.paint(ui.painter(), r.shrink(if focused || invalid { 1.0 } else { 0.0 }), radius, true, ui.is_enabled());
}
// Closed with less room than its hint takes (a narrow bar): just the magnifier.
if !open && progress == 0.0 && r.width() + 0.5 < Self::closed_width(ui.ctx()) {
if !open && progress == 0.0 && r.width() + 0.5 < Self::closed_width(ui.ctx(), key) {
icons::paint_icon(ui.painter(), Rect::from_center_size(r.center(), vec2(t.space_16, t.space_16)), "zoom", 1.0);
solidcraft_ui_registry::publish_target("ribbon:search", r);
solidcraft_ui_registry::publish_target("shortcut:search", r);
@@ -139,13 +143,11 @@ impl CommandSearch {
"zoom",
1.0,
);
let cap = if open { "Esc" } else { "S" };
let key_width = Self::key_with_padding(ui, r.right_center() - vec2(spacing.right, 0.0), cap, spacing.key_padding);
let cap = if open { Some("Esc") } else { key };
let key_width = cap
.map_or(0.0, |cap| Self::key_with_padding(ui, r.right_center() - vec2(spacing.right, 0.0), cap, spacing.key_padding) + spacing.key_gap);
let editor_left = (r.left() + spacing.left + t.space_16 + spacing.icon_gap).min(r.right());
let editor = Rect::from_min_max(
pos2(editor_left, r.top()),
pos2((r.right() - spacing.right - key_width - spacing.key_gap).max(editor_left), r.bottom()),
);
let editor = Rect::from_min_max(pos2(editor_left, r.top()), pos2((r.right() - spacing.right - key_width).max(editor_left), r.bottom()));
let mono = value.contains('{');
let response = ui
.scope_builder(egui::UiBuilder::new().max_rect(editor), |ui| {
@@ -389,7 +391,7 @@ mod tests {
let ctx = crate::fonts::test_context();
let mut query = String::new();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, 4096);
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, Some("S"), 4096);
});
output.textures_delta.clear();
let shapes: Vec<_> = output
@@ -423,7 +425,7 @@ mod tests {
let ctx = crate::fonts::test_context();
let mut query = String::new();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, 4096);
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, Some("S"), 4096);
});
output.textures_delta.clear();
for shape in output.shapes {
+1 -1
View File
@@ -237,7 +237,7 @@ impl StageBar {
result.stage = Some(index);
}
}
let search_width = super::CommandSearch::closed_width(ui.ctx()).max(188.0);
let search_width = super::CommandSearch::closed_width(ui.ctx(), Some("S")).max(188.0);
let action_width = actions.iter().map(|a| super::actions::icon_label_width(ui, a.label, a.icon, a.primary)).sum::<f32>();
ui.add_space((ui.available_width() - search_width - action_width - t.space_4 * (actions.len() + 2) as f32).max(t.space_8));
for (index, item) in actions.iter().enumerate() {
+10 -1
View File
@@ -557,7 +557,16 @@ Rows are sorted by part number (rows without one come last) and hold `item`, `pa
`description`, `revision`, `bom_type`, `count`, `definition` (the component names), `components` (their ids)
and `properties`, with `total_occurrences` beside them. `path` writes them to a `.csv` file
(the columns item, part_number, description, revision, count and definition, every field
quoted; children follow their row) or a `.json` file. The window lists the same rows, switches
quoted, and a field that a spreadsheet would run as a formula written with a `'` before it so the
spreadsheet shows it as text; children follow their row) or a `.json` file. A formula field is one that
starts with `=`, `+`, `-` or `@`, their full-width forms, a tab or a line break, after any spaces and
`'`; a plain number such as `-5` is not one. A script that reads the CSV sees the `'` too: a part number
`=1+1` is `'=1+1` in the file, and `'=1+1` is `''=1+1`. To get the value back, take off one leading `'`
when what follows starts a formula, as `solidcraft_doc::csv::unguard` does, or read the `.json` file,
which holds the values unchanged. 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), and the formula then runs the next
time that file opens. Export again from SolidCraft instead of passing on a CSV a spreadsheet has saved.
The window lists the same rows, switches
between the levels and groupings and shows children under their part, edits a picked row's part
number, description and revision, and exports them. Changing part properties changes no
geometry, so it doesn't evaluate the model or re-seat joints again.
+7
View File
@@ -102,6 +102,13 @@ an unchanged field cannot reinterpret it. Computed sizes and tolerances keep the
precision when written back into editable fields, independently of display precision.
Parameters export to and import from CSV or JSON (`parameters.export`, `parameters.import`).
In a CSV, a value that a spreadsheet would run as a formula is written with a `'` before it, so the
spreadsheet shows it as text. That is a value that starts with `=`, `+`, `-` or `@`, their full-width
forms, a tab or a line break, after any spaces and `'`; a plain number such as `-5` is left as it is.
The import takes the `'` off again. A spreadsheet that saves the file again as CSV can drop the `'`
(Excel does), so export again from SolidCraft instead of passing that file on. Earlier versions of
SolidCraft wrote every value unchanged, so in a CSV from one of them, a value whose first character
really was a `'` typed before a formula character (`'=x`) now imports without it (`=x`).
**Configurations** (`config.*`) keep a table of rows, each with its own parameter values and
suppressed features, physical materials and body appearances, switched with `config.activate`.
The table's Library picker selects material or appearance values. Choose **Follow material**
+1
View File
@@ -791,6 +791,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Command | Id | Toolbar |
|---|---|---|
| Toggle Assistant (Ctrl+Shift+A) | `ui.assistant` | – |
| Search Commands (S) | `ui.palette` | – |
## `view.*`
+3 -1
View File
@@ -474,7 +474,9 @@ Feature replies omit the full `bodies` list while keeping `feature`, `name`,
`created_bodies`, `changed_bodies` and `removed_bodies`. A cut can therefore report a
changed body even when no body name was added or removed. Sketch replies keep new
IDs under `created` and omit only identical top-level `points`, `curves`,
`constraints` and `dimensions` lists. Other fields and errors are unchanged;
`constraints` and `dimensions` lists. When a sketch command creates more points than
its new curves have ends (the poles of an offset's splines), `created.points` becomes
`created.point_count`: refer to those curves by their ids. Other fields and errors are unchanged;
inspection commands still return their complete results. Compaction uses the
engine's reply directly, without an additional design-inspection request.
+3 -10
View File
@@ -54,18 +54,14 @@ function parseDefaults(body) {
if (pref && pref[1].trim() && !/^<.*>$/.test(pref[2])) {
return { status: 'yes', detail: pref[1].trim(), preference: pref[2] };
}
// solidcore's rule is the opposite of the app's: a kernel result change becomes the default and the
// old path is deleted, no flag (solidcore/AGENTS.md §4b). Accepted only for PRs that stay in solidcore/.
const kernel = yes[1].match(/^(.*?)[ ,;]*\bkernel result change\b[ ,;]*no (?:preference|flag)\.?$/i);
if (kernel && kernel[1].trim()) return { status: 'kernel', detail: kernel[1].trim() };
}
return { status: 'malformed', detail: value };
}
const DEFAULTS_HELP =
'Add one line to the description: `Changes a default: no`, or ' +
'(solidcore/ only) `Changes a default: yes — <what changes>; kernel result change, no preference`, or ' +
'`Changes a default: yes — <what>, old behaviour kept behind preference <name>`.';
'`Changes a default: yes — <what>, old behaviour kept behind preference <name>`. ' +
'(UX defaults only; a kernel result change is \'no\'.)';
function isBot(user) {
return !!user && (user.type === 'Bot' || BOT_LOGINS.has(user.login) || /\[bot\]$/.test(user.login || ''));
@@ -156,10 +152,7 @@ async function checkDefaults({ github, context, core }) {
})).map((f) => f.filename);
if (isDocsOnly(files)) return { skipped: 'docs only' };
const result = parseDefaults(pull.body);
if (result.status === 'kernel' && !files.every((f) => f.startsWith('solidcore/') || DOC_FILE.test(f))) {
core.setFailed('"kernel result change, no preference" is only for pull requests that change solidcore/ alone; ' +
`a change to the app keeps its old behaviour behind a preference. ${DEFAULTS_HELP}`);
} else if (result.status === 'missing') {
if (result.status === 'missing') {
core.setFailed(`The description has no "Changes a default:" line. ${DEFAULTS_HELP}`);
} else if (result.status === 'malformed') {
core.setFailed(`Could not read "Changes a default: ${result.detail}". ${DEFAULTS_HELP}`);
-7
View File
@@ -47,13 +47,6 @@ test('parseDefaults yes forms', () => {
assert.equal(pc.parseDefaults('Changes a default: yes — x, old behaviour kept behind preference <name>').status, 'malformed');
});
test('parseDefaults kernel result change (solidcore/ only)', () => {
const r = pc.parseDefaults('Changes a default: yes — the fillet corner patch is G2 now; kernel result change, no preference');
assert.equal(r.status, 'kernel');
assert.equal(r.detail, 'the fillet corner patch is G2 now');
assert.equal(pc.parseDefaults('Changes a default: yes - kernel result change, no preference').status, 'malformed');
});
test('isBot and isDocsOnly', () => {
assert.ok(pc.isBot({ login: 'dependabot[bot]', type: 'Bot' }));
assert.ok(pc.isBot({ login: 'solidcraft-generated[bot]' }));
+1
View File
@@ -18,6 +18,7 @@ The authority is `solidcore/docs/layers.md`; this is its short form. Every job l
## Lists that only shrink
- `xtask/mesh-decisions.txt`: model decisions made from a mesh (`cargo xtask mesh-decisions`).
- `xtask/app-geometry.txt`: functions in the app crates that compute geometry (`cargo xtask app-geometry`). A new one fails CI; when you move one into `solidcore/`, delete its line.
- `solidcore/plan/panic-sites.tsv`: panic-prone calls in solidcore (SolidCore checks in CI).
## Working in solidcore/
+1 -1
View File
@@ -22,4 +22,4 @@ Settings live in the `preference_settings!` table in `crates/ui-egui/src/prefs.r
- Test it in a UI scenario or a `*_tests.rs` file next to the setting (`skills/solidcraft-tests/SKILL.md`).
## What counts as a default
Anything a user sees or does differently on first launch after the update: a changed shortcut, camera or navigation behaviour, unit, naming, theme or autosave behaviour, or a tool that appears unasked. A fix that makes the app do what it already claimed is not a default change; say so on the line.
Anything a user sees or does differently on first launch after the update: a changed shortcut, camera or navigation behaviour, unit, naming, theme or autosave behaviour, or a tool that appears unasked. 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. Not defaults: bug fixes and more correct results, kernel or geometry result changes (write `Changes a default: no`), new options that are off by default, and CI or tooling config. A fix that makes the app do what it already claimed is not a default change; say so on the line.
+1 -3
View File
@@ -33,7 +33,5 @@ solidcraft-kernel::corner_g2::tests::set_back_8_the_g2_corner_lies_between_the_h
solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, as on C++
solidcraft-kernel::fillet_setback_tests::a_setback_not_past_the_radius_is_refused_naming_the_least solidcore builds setback 5.5 (15 faces) where SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310 refuses it naming the least that builds; nothing names the least setback on solidcore until then
solidcraft-kernel::native::tests::box_and_fillet_have_stable_wire_records the box-edge round's wire record differs from the fixture at solidcore 8ddce9016 (a value 3 is 5): the fixture is re-blessed (NATIVE_BREP_BLESS=1) once the change is shown right
solidcraft-kernel::smooth_edges::tests::a_seam_split_in_two_edges_is_drawn_whole fails since solidcore c0d3cc0124 (passed at a445bdaac6), vendoring cutover (#13534)
solidcraft-smlib::core::blend_tests::a_setback_corner_left_open_returns_its_hole fails since solidcore c0d3cc0124 (passed at a445bdaac6), vendoring cutover (#13534)
solidcraft-smlib::ops::tests::a_setback_corner_left_open_returns_its_hole fails since solidcore c0d3cc0124 (passed at a445bdaac6), vendoring cutover (#13534)
solidcraft-kernel::smooth_edges::tests::a_g2_corner_s_side_to_its_round_is_drawn_whole a G2 corner's side to its round is one edge since solidcore #2988, but isn't drawn whole: shaded_edge_visible false (#13542)
solidcraft-smlib::ops::tests::a_variable_round_with_a_corner_in_its_law_is_its_balls_union solidcore refuses a variable fillet whose law has a corner (solidcore#430)
+1
View File
@@ -330,6 +330,7 @@ timeline.play plays the history back in the view: no new geo
timeline.rename a feature name
timeline.rename_group a group name
ui.assistant the view: camera, layout, display
ui.palette the view: the command search; tested in crates/ui-egui/src/keymap.rs
view.ambient_occlusion the view: camera, layout, display
view.angle_readout the view: camera, layout, display
view.apply the view: camera, layout, display
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
//! `cargo xtask app-geometry [--print]`: apps compute no geometry (`solidcore/docs/layers.md` §4,
//! RFC 0003 row 0.1). Vector maths on coordinates, solves and mesh queries belong in the kernel
//! (solidcore); the crates here hold state and call it. The check keeps a ledger of the functions
//! that still compute geometry, [`LIST`], and the ledger only shrinks: a refactor PR deletes its
//! lines and no PR adds one.
//!
//! The heuristic, deliberately plain. The check reads the non-test Rust of the crates in
//! [`CRATES`], following each crate's module tree from its `lib.rs` (a `#[cfg(test)]` or
//! `#[test]` item is left out), and flags every function whose body (closures, nested blocks and
//! expression macros such as `format!` included) uses at least one geometry operation, named by
//! [`geometry_use`]:
//! - vector maths, as a method call: `.dot(v)`, `.cross(v)`, `.normalize()`, `.normalized()`,
//! `.magnitude()`, `.magnitude2()`, `.distance(p)`, `.distance2(p)`, `.hypot(y)`, `.atan2(x)`,
//! `.angle(v)`;
//! - solves: `.solve(..)`;
//! - mesh queries: `.raycast(..)`, `.tangent_chain(..)`, the `.triangles` field and
//! `closest_on_triangle(..)`.
//!
//! It is by name, not by type: a method with one of those names counts whoever defines it. It
//! does not grade `sqrt`, `sin`, `cos`, `length`, `lerp` or plain arithmetic (too common in
//! layout, colour and timing code), so the ledger is a floor of the sites, not a complete count.
//! A function is `<path>:<scope>`, the enclosing inline modules, impl type and function joined
//! by `::`. Nested functions and closures count toward the function they sit in; two functions
//! with one name in one impl type (trait impls) are one site.
//!
//! [`LIST`] holds one site a line, `<path>:<function> <the operations it uses>`, sorted (the
//! kernel rig's known-failing format; the operations are for the reader and are not graded). The
//! check fails on a site the list does not name, and on a listed site that no longer computes
//! geometry or no longer exists: its refactor removes its line, as with
//! `xtask/mesh-decisions.txt`. `--print` writes the current sites in the list's format, to see
//! what a change did; it is not a way to refresh the list.
use std::collections::BTreeMap;
use std::path::Path;
use syn::visit::{self, Visit};
use crate::mesh_decisions::{module_files, relative, test_only, type_name};
pub const LIST: &str = "xtask/app-geometry.txt";
/// The crates that are apps or sit between an app and the kernel: the document, the engine, the
/// UI, file formats, and SolidCraft's own kernel and geometry layers, whose geometry moves into
/// solidcore.
pub const CRATES: &[&str] = &["crates/doc", "crates/engine", "crates/ui-egui", "crates/io", "crates/kernel", "crates/geom"];
/// The geometry operation `call` is, if it is one.
fn geometry_use(call: &Call) -> Option<&'static str> {
match (call, call.name()) {
(Call::Method { args: 1, .. }, "dot") => Some("dot"),
(Call::Method { args: 1, .. }, "cross") => Some("cross"),
(Call::Method { args: 0, .. }, "normalize") => Some("normalize"),
(Call::Method { args: 0, .. }, "normalized") => Some("normalized"),
(Call::Method { args: 0, .. }, "magnitude") => Some("magnitude"),
(Call::Method { args: 0, .. }, "magnitude2") => Some("magnitude2"),
(Call::Method { args: 1, .. }, "distance") => Some("distance"),
(Call::Method { args: 1, .. }, "distance2") => Some("distance2"),
(Call::Method { args: 1, .. }, "hypot") => Some("hypot"),
(Call::Method { args: 1, .. }, "atan2") => Some("atan2"),
(Call::Method { args: 1, .. }, "angle") => Some("angle"),
(Call::Method { .. }, "solve") => Some("solve"),
(Call::Method { .. }, "raycast") => Some("raycast"),
(Call::Method { .. }, "tangent_chain") => Some("tangent_chain"),
(Call::Function { .. }, "closest_on_triangle") => Some("closest_on_triangle"),
(Call::Field { .. }, "triangles") => Some("triangles"),
_ => None,
}
}
enum Call {
Method { name: String, args: usize },
Function { name: String },
Field { name: String },
}
impl Call {
fn name(&self) -> &str {
match self {
Call::Method { name, .. } | Call::Function { name } | Call::Field { name } => name,
}
}
}
pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
let found = scan(root)?;
if args.contains(&"--print") {
for (site, uses) in &found {
println!("{site} {}", uses.join(","));
}
return Ok(());
}
let text = std::fs::read_to_string(root.join(LIST)).map_err(|e| format!("{LIST}: {e}"))?;
println!("{}", grade(&text, &found).map_err(|e| format!("{LIST}: {e}"))?);
Ok(())
}
/// Every site under `root`: `<path>:<function>` with the operations it uses, sorted.
fn scan(root: &Path) -> Result<BTreeMap<String, Vec<&'static str>>, String> {
let mut found = BTreeMap::new();
for krate in CRATES {
let mut files = Vec::new();
module_files(&root.join(krate).join("src/lib.rs"), true, &mut files)?;
files.sort();
files.dedup();
for path in files {
let rel = relative(root, &path);
let source = std::fs::read_to_string(&path).map_err(|e| format!("{rel}: {e}"))?;
for (function, uses) in sites(&source).map_err(|e| format!("{rel}: {e}"))? {
found.insert(format!("{rel}:{function}"), uses);
}
}
}
Ok(found)
}
/// The functions of `source` that compute geometry, with the operations each uses (outside test
/// code), sorted.
pub fn sites(source: &str) -> Result<BTreeMap<String, Vec<&'static str>>, String> {
let ast = syn::parse_file(source).map_err(|e| format!("cannot parse Rust source: {e}"))?;
let mut finder = Finder { scope: Vec::new(), fns: 0, found: BTreeMap::new() };
finder.visit_file(&ast);
for uses in finder.found.values_mut() {
uses.sort_unstable();
}
Ok(finder.found)
}
struct Finder {
/// The enclosing inline modules, impl type and function, outermost first.
scope: Vec<String>,
/// How many functions enclose the code being visited: a use belongs to the outermost one.
fns: usize,
found: BTreeMap<String, Vec<&'static str>>,
}
impl Finder {
fn note(&mut self, call: Call) {
let Some(what) = geometry_use(&call) else { return };
// Outside any function (a const or static initialiser) the use belongs to that item.
let site = if self.scope.is_empty() { "<module>".to_owned() } else { self.scope.join("::") };
let uses = self.found.entry(site).or_default();
if !uses.contains(&what) {
uses.push(what);
}
}
fn scoped(&mut self, name: String, f: impl FnOnce(&mut Self)) {
self.scope.push(name);
f(self);
self.scope.pop();
}
/// A function: inside another function it joins that one (a nested `fn` is part of its
/// parent's site), otherwise it is a site of its own.
fn function(&mut self, name: String, f: impl FnOnce(&mut Self)) {
if self.fns > 0 {
f(self);
} else {
self.fns += 1;
self.scoped(name, f);
self.fns -= 1;
}
}
/// A macro's arguments, when they are expressions (`format!`, `vec!`, `assert!`).
fn macro_args(&mut self, mac: &syn::Macro) {
let parser = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
if let Ok(args) = mac.parse_body_with(parser) {
for e in &args {
self.visit_expr(e);
}
} else if let Ok(block) = mac.parse_body_with(syn::Block::parse_within) {
for s in &block {
self.visit_stmt(s);
}
}
}
}
impl<'ast> Visit<'ast> for Finder {
fn visit_item_mod(&mut self, m: &'ast syn::ItemMod) {
if !test_only(&m.attrs) && m.content.is_some() {
self.scoped(m.ident.to_string(), |f| visit::visit_item_mod(f, m));
}
}
fn visit_item_fn(&mut self, i: &'ast syn::ItemFn) {
if !test_only(&i.attrs) {
self.function(i.sig.ident.to_string(), |f| visit::visit_item_fn(f, i));
}
}
fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) {
if !test_only(&i.attrs) {
self.scoped(type_name(&i.self_ty), |f| visit::visit_item_impl(f, i));
}
}
fn visit_impl_item_fn(&mut self, i: &'ast syn::ImplItemFn) {
if !test_only(&i.attrs) {
self.function(i.sig.ident.to_string(), |f| visit::visit_impl_item_fn(f, i));
}
}
fn visit_item_trait(&mut self, i: &'ast syn::ItemTrait) {
if !test_only(&i.attrs) {
self.scoped(i.ident.to_string(), |f| visit::visit_item_trait(f, i));
}
}
fn visit_trait_item_fn(&mut self, i: &'ast syn::TraitItemFn) {
if !test_only(&i.attrs) {
self.function(i.sig.ident.to_string(), |f| visit::visit_trait_item_fn(f, i));
}
}
fn visit_item_const(&mut self, i: &'ast syn::ItemConst) {
if !test_only(&i.attrs) {
self.scoped(i.ident.to_string(), |f| visit::visit_item_const(f, i));
}
}
fn visit_item_static(&mut self, i: &'ast syn::ItemStatic) {
if !test_only(&i.attrs) {
self.scoped(i.ident.to_string(), |f| visit::visit_item_static(f, i));
}
}
fn visit_stmt(&mut self, s: &'ast syn::Stmt) {
if let syn::Stmt::Local(l) = s
&& test_only(&l.attrs)
{
return;
}
visit::visit_stmt(self, s);
}
fn visit_expr_method_call(&mut self, e: &'ast syn::ExprMethodCall) {
self.note(Call::Method { name: e.method.to_string(), args: e.args.len() });
visit::visit_expr_method_call(self, e);
}
fn visit_expr_call(&mut self, e: &'ast syn::ExprCall) {
if let syn::Expr::Path(p) = e.func.as_ref()
&& let Some(last) = p.path.segments.last()
{
self.note(Call::Function { name: last.ident.to_string() });
}
visit::visit_expr_call(self, e);
}
fn visit_expr_field(&mut self, e: &'ast syn::ExprField) {
if let syn::Member::Named(name) = &e.member {
self.note(Call::Field { name: name.to_string() });
}
visit::visit_expr_field(self, e);
}
fn visit_macro(&mut self, mac: &'ast syn::Macro) {
self.macro_args(mac);
visit::visit_macro(self, mac);
}
}
/// The crate of a site (`crates/<name>/...`).
fn crate_of(site: &str) -> &str {
site.strip_prefix("crates/").and_then(|s| s.split('/').next()).unwrap_or("?")
}
/// The per-crate counts of `sites`, in [`CRATES`] order: `doc 12, engine 3, ...`.
fn counts<'a>(sites: impl Iterator<Item = &'a str>) -> String {
let mut n: BTreeMap<&str, usize> = BTreeMap::new();
for s in sites {
*n.entry(crate_of(s)).or_default() += 1;
}
CRATES.iter().map(|c| c.trim_start_matches("crates/")).map(|c| format!("{c} {}", n.get(c).unwrap_or(&0))).collect::<Vec<_>>().join(", ")
}
/// The gate: every site in `found` is listed in `list`, and every listed site is still found.
fn grade(list: &str, found: &BTreeMap<String, Vec<&'static str>>) -> Result<String, String> {
let listed = crate::kernel_cases::known_failing(list)?;
let mut problems = Vec::new();
for (site, uses) in found {
if !listed.iter().any(|(s, _)| s == site) {
problems.push(format!(
"NEW: {site} computes geometry ({}): call the kernel (solidcore ops) for it instead; apps hold state and compute no geometry",
uses.join(", ")
));
}
}
for (site, _) in &listed {
if !found.contains_key(*site) {
problems.push(format!("FIXED: {site} no longer computes geometry (or is gone): remove its line"));
}
}
let by_crate = counts(listed.iter().map(|(s, _)| *s));
if problems.is_empty() {
Ok(format!("app-geometry: {} sites compute geometry ({by_crate}); no new ones", listed.len()))
} else {
Err(format!("{} problem(s) (listed: {by_crate}):\n {}", problems.len(), problems.join("\n ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn geometry_is_found_per_function_outside_tests() {
let s = sites(
r#"
fn tilt(n: V, u: V) -> f64 { n.dot(u).acos() }
struct Rig;
impl Rig {
fn frame(&self, a: V, b: V) -> V { let f = |x: V| x.cross(b); f(a).normalized() }
fn nested(&self) { fn inner(a: V) -> f64 { a.magnitude() } let _ = inner; }
fn plain(&self, a: f64) -> f64 { a.sqrt() + a.sin() }
fn arity(&self, a: V) -> f64 { a.dot() }
}
fn in_macro(a: V, b: V) -> String { format!("{}", a.distance(b)) }
fn mesh(m: &Mesh) -> usize { m.triangles.len() }
#[cfg(test)] mod tests { fn fixture(a: V, b: V) { a.dot(b); } }
#[test] fn t(a: V, b: V) { a.dot(b); }
mod inner { fn nearest(p: V) -> V { closest_on_triangle(p, p, p, p) } }
"#,
)
.unwrap();
let keys: Vec<&str> = s.keys().map(String::as_str).collect();
assert_eq!(keys, ["Rig::frame", "Rig::nested", "in_macro", "inner::nearest", "mesh", "tilt"]);
assert_eq!(s["Rig::frame"], ["cross", "normalized"]);
assert_eq!(s["Rig::nested"], ["magnitude"], "a nested fn counts toward its parent");
assert!(sites("invalid Rust {{").is_err(), "unparseable source fails closed");
}
const LISTED: &str = "# comment\ncrates/doc/src/a.rs:tilt dot\ncrates/ui-egui/src/b.rs:near distance\n";
fn found(names: &[&str]) -> BTreeMap<String, Vec<&'static str>> {
names.iter().map(|n| (n.to_string(), vec!["dot"])).collect()
}
#[test]
fn listed_sites_pass_and_counts_are_per_crate() {
let ok = grade(LISTED, &found(&["crates/doc/src/a.rs:tilt", "crates/ui-egui/src/b.rs:near"])).unwrap();
assert!(ok.contains("doc 1, engine 0, ui-egui 1, io 0, kernel 0, geom 0"), "{ok}");
}
#[test]
fn a_new_site_fails() {
let e = grade(LISTED, &found(&["crates/doc/src/a.rs:tilt", "crates/ui-egui/src/b.rs:near", "crates/doc/src/a.rs:pick"])).unwrap_err();
assert!(e.contains("NEW: crates/doc/src/a.rs:pick"), "{e}");
}
#[test]
fn a_gone_site_still_listed_fails() {
let e = grade(LISTED, &found(&["crates/doc/src/a.rs:tilt"])).unwrap_err();
assert!(e.contains("FIXED: crates/ui-egui/src/b.rs:near"), "{e}");
}
#[test]
fn the_list_is_sorted_with_a_reason_each() {
assert!(grade("b.rs:near x\na.rs:tilt x\n", &found(&["a.rs:tilt", "b.rs:near"])).is_err());
assert!(grade("a.rs:tilt\n", &found(&["a.rs:tilt"])).is_err());
}
#[test]
fn the_workspace_has_no_new_app_geometry() {
run(Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(), &[]).unwrap();
}
}
+10 -1
View File
@@ -3,6 +3,7 @@
//! Rust workspace tooling. External tools (`cargo`, and Python 3.11+ for the
//! original icon collection) are invoked through `std::process::Command`.
mod app_geometry;
mod app_icons;
mod assets;
mod book;
@@ -121,6 +122,12 @@ commands:
geometry: fails on a function outside display, export, previews and mesh bodies
that uses a mesh and xtask/mesh-decisions.txt does not list, and on a listed one
that no longer does (the list only shrinks)
app-geometry [--print]
check that apps compute no geometry: fails on a function in crates/doc, engine,
ui-egui, io, kernel or geom that does vector maths, solves or mesh queries and
xtask/app-geometry.txt does not list, and on a listed one that no longer does
(the list only shrinks); prints the listed count per crate. --print writes the
sites found, in the list's format
test [CARGO ARGS] [-- TEST ARGS]
build the tests with the ci profile, then run every test binary at once
(and the doc tests alongside); SOLIDCRAFT_TEST_JOBS=N bounds concurrent test
@@ -136,7 +143,7 @@ commands:
check in headless Chrome that the web app works offline and updates to a new
build (a stand-in app, or the web build in DIR such as dist/web)
ci fmt --check, assets, icons, licences --check, lists, skills, layers, shared,
ui-containers, mesh-decisions, clippy -D warnings, then the tests
ui-containers, mesh-decisions, app-geometry, clippy -D warnings, then the tests
with the wasm check alongside, then web-offline-test when Chrome is installed
(stops at the first failing step)
";
@@ -166,6 +173,7 @@ fn main() -> ExitCode {
Some("fold-pending") => pending::run(&root()),
Some("skills") => skills::run(&root()),
Some("mesh-decisions") => mesh_decisions::run(&root()),
Some("app-geometry") => app_geometry::run(&root(), &rest),
Some("lists") => lists::run(&root(), rest.contains(&"--fix")),
Some("test") => {
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..]));
@@ -477,6 +485,7 @@ fn cmd_ci() -> Result<(), String> {
shared::run(&root())?;
ui_containers::run(&root())?;
mesh_decisions::run(&root())?;
app_geometry::run(&root(), &[])?;
let mut c = cargo();
c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]);
run(c, "cargo clippy -D warnings")?;
+4 -4
View File
@@ -178,14 +178,14 @@ fn scan_skipping(root: &Path, modules: &[(&str, &str)], functions: &[(&str, &str
Ok(found)
}
fn relative(root: &Path, path: &Path) -> String {
pub(crate) fn relative(root: &Path, path: &Path) -> String {
path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/")
}
/// The source files of the module at `file` and of every non-test module below it, into `out`.
/// `owns_dir` is whether `file` is a `lib.rs` or `mod.rs` (its submodules sit beside it) rather
/// than `name.rs` (its submodules sit in `name/`).
fn module_files(file: &Path, owns_dir: bool, out: &mut Vec<PathBuf>) -> Result<(), String> {
pub(crate) fn module_files(file: &Path, owns_dir: bool, out: &mut Vec<PathBuf>) -> Result<(), String> {
let source = std::fs::read_to_string(file).map_err(|e| format!("{}: {e}", file.display()))?;
let ast = syn::parse_file(&source).map_err(|e| format!("{}: cannot parse Rust source: {e}", file.display()))?;
out.push(file.to_path_buf());
@@ -244,7 +244,7 @@ fn path_attribute(attrs: &[syn::Attribute]) -> Option<String> {
/// Whether `attrs` keep an item to test builds: `#[test]`, `#[cfg(test)]` or
/// `#[cfg(all(.., test, ..))]`.
fn test_only(attrs: &[syn::Attribute]) -> bool {
pub(crate) fn test_only(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|a| {
if a.path().is_ident("test") {
return true;
@@ -310,7 +310,7 @@ impl Finder {
}
}
fn type_name(ty: &syn::Type) -> String {
pub(crate) fn type_name(ty: &syn::Type) -> String {
match ty {
syn::Type::Path(p) => p.path.segments.last().map_or_else(|| "<type>".to_owned(), |s| s.ident.to_string()),
syn::Type::Reference(r) => type_name(&r.elem),