Compare commits

...

96 Commits

Author SHA1 Message Date
jyviko 0397be0f7c 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>
2026-11-08 08:28:45 -05:00
jyviko 4384c52448 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>
2026-11-08 08:28:44 -05:00
jyviko b2d5044fbd 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>
2026-11-08 08:28:44 -05:00
jyviko c2436a98f1 View compass: the module list in rustfmt's order (#12944)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:16 -05:00
jyviko bfe3da9c3d 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>
2026-11-08 08:26:16 -05:00
jyviko ec50f99608 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>
2026-11-08 08:26:16 -05:00
jyviko 95176d0326 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>
2026-11-08 08:26:16 -05:00
jyviko f0e2c95dd0 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>
2026-11-08 08:26:16 -05:00
jyviko 5dbfa589bf 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>
2026-11-08 08:26:16 -05:00
jyviko d3ba5d414b 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>
2026-11-08 08:26:16 -05:00
jyviko 72254ce90d 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
2026-11-08 08:26:16 -05:00
jyviko c2eeb2c5ed 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
2026-11-08 08:26:16 -05:00
jyviko 2724151d04 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>
2026-11-08 08:26:03 -05:00
jyviko d83cc2e48b 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>
2026-11-08 08:10:13 -05:00
jyviko 4b6efddb3c 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>
2026-11-08 08:10:13 -05:00
jyviko de146e349b 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>
2026-11-08 08:10:13 -05:00
jyviko d83d6b3c1e 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>
2026-11-08 08:10:13 -05:00
jyviko 50c78b30dd 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>
2026-11-08 08:10:13 -05:00
jyviko 26db4c7777 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
2026-11-08 08:10:13 -05:00
jyviko 363ec793dc 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
2026-11-08 08:10:13 -05:00
jyviko bd4f6e857d 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>
2026-11-08 08:10:03 -05:00
jyviko 31c6f39e98 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>
2026-11-08 08:09:51 -05:00
jyviko 731749224d 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>
2026-11-08 08:09:51 -05:00
jyviko a54ff93c59 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>
2026-11-08 08:09:50 -05:00
jyviko d2b9daeba8 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
2026-11-08 08:09:50 -05:00
jyviko 223ccd0004 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
2026-11-08 08:09:50 -05:00
jyviko 331218f23b 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
2026-11-08 08:09:50 -05:00
jyviko c361491d57 Docs: the architecture table lists the shared crates and the MCP split (#12947)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:50 -05:00
jyviko 24ba6f29ca 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>
2026-11-08 08:09:50 -05:00
jyviko 3edfed7219 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
2026-11-08 08:09:50 -05:00
jyviko 0383f87e57 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
2026-11-08 08:06:19 -05:00
jyviko bb91986df3 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
2026-11-08 08:06:19 -05:00
jyviko af73d7bd90 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
2026-11-08 08:06:19 -05:00
jyviko 7d86e00ca7 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
2026-11-08 08:04:35 -05:00
jyviko e894f4a12f 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>
2026-11-08 07:56:31 -05:00
jyviko 7b9e66929b 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
2026-11-08 07:56:31 -05:00
jyviko bd3a94e946 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
2026-11-08 07:49:43 -05:00
jyviko b0d7219115 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
2026-11-08 07:49:43 -05:00
jyviko a7598a92c6 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
2026-11-08 07:49:43 -05:00
jyviko 5c4932ba42 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
2026-11-08 07:49:43 -05:00
jyviko ee72a98948 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
2026-11-08 07:49:21 -05:00
jyviko 2fba25f564 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
2026-11-08 07:49:09 -05:00
jyviko 7b90cdf3fb 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
2026-11-08 07:49:09 -05:00
jyviko 8b7586ba87 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
2026-11-08 07:49:09 -05:00
jyviko b23d1ada5d 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
2026-11-08 07:49:09 -05:00
jyviko 9a0d9ce243 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
2026-11-08 07:49:09 -05:00
jyviko be226abee7 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
2026-11-08 07:49:09 -05:00
jyviko 414b8bb139 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
2026-11-08 07:49:09 -05:00
MarkusRe 8706d623fb Agents and scripts: Flat Pattern, Export Bend Table and Path Flange describe their exact parameters and replies (#6581) (#12545)
* claim: #6581

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

* Sheet metal: schemas for flat pattern, bend table and path flange (#6581)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:02 +00:00
github-actions[bot] ae1d85e5d4 Regenerate generated files and fold pending files [skip ci] 2026-11-08 07:38:32 +00:00
github-actions[bot] 0d350e7b38 Regenerate generated files and fold pending files [skip ci] 2026-11-08 07:27:50 +00:00
PeppaWutz e69dfe126c Agents and scripts: Select and Clear Selection describe their exact parameters and reply (#13132) (#13174)
* Agents and scripts: Select and Clear Selection describe their exact parameters and reply (#13132)

select.set and select.clear now publish typed input and output schemas.
Each item of select.set is read as the handler reads it today, with the
same error messages, and both replies are built from the typed value and
serialize to the same JSON as before. Their lines leave unconverted.txt.

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

* Select: the item test matches each item to the branch of its type

`conforms` doesn't check `const`, so every_selection_kind_is_taken now also
finds the SelectItem branch whose `type` const is the item's and checks the
item against it, and checks that every branch is tried.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 06:44:26 +00:00
0rdnungsamt 3c4b336080 Sketch: a preference to project references as construction geometry (#13142) (#13166)
* claim: #13142

* Sketch: a preference to project references as construction geometry (#13142)

Project, Intersect, Include 3D Geometry and the face edges a new sketch on a
face projects all came in as normal geometry, so they joined the sketch's
profiles even when they were only meant as references to draw against.

A new preference, "Project references as construction geometry" (Preferences >
Sketch, off by default), makes them start as construction geometry. They stay
linked to the model. It is the user's, like "Project face edges into new
sketches": it is kept when another design is opened or started, follows the
user across tabs and is saved with the preferences. sketch.project,
sketch.intersect and sketch.include_3d take an optional `construction`
boolean so scripts and agents can choose per call.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:34:21 +00:00
0rdnungsamt 1ec5eda955 Schemas: the rectangle and polygon sketch commands describe their parameters and replies (#13197)
* claim: #13196

* Schemas: the rectangle and polygon sketch commands describe their parameters and replies (#13196)

The seven commands sketch.rectangle.two_point, three_point, center, rounded
and sketch.polygon.inscribed, circumscribed, edge get a typed input contract
and an output schema; their replies are built from the reply types and their
lines leave schema/unconverted.txt. A test that passed the rectangle an `ids`
key it never read no longer does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:33:30 +00:00
Sebastian 7bb016e5e4 Press Pull: the dialog the Q shortcut opens is titled "Press Pull", not "Extrude" (#11705) (#11712)
* claim: #11705

* Press Pull: the dialog the Q shortcut opens is titled PRESS PULL, not EXTRUDE (#11705)

Pressing Q (or picking Press Pull) with a planar face, a profile or
nothing selected opened the Extrude panel, so the shortcut and the
window disagreed. The panel now says PRESS PULL whenever it was opened
by Press Pull; a plain Extrude (E) still says EXTRUDE.

- lib.rs: the extrude dialog opened by solid.press_pull always carries
  the press_pull marker now (before, only with an empty selection)
- dialogs.rs: heading and issue-report title use PRESS PULL for it
- new ui.heading_press_pull message in assets/locales/pending/
- regression test in preselect_tests.rs + UI scenario pressing Q and E

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Address review on #11712: Press Pull header, attribution row, precise test assertions

- dialogs.rs: the panel body header of a Press Pull extrude uses the
  solid.press_pull command (icon and label) instead of Extrude's
- attribution/pending/press-pull-dialog-title.md: row for the pending
  locale asset
- preselect_tests.rs: assert the drawn header by accessibility role and
  place in the dialog header band, for Q and for plain E
- scenario: check the press_pull flag and the painted header label

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Fix f64/f32 in the header-label test assertion

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Pin the header-label test to the dialog header band (no role filter)

The accesskit tree carries the painted title under its own node, not
Role::Label; the header band rect is the precise anchor, so a
same-named toolbar widget cannot satisfy the assertion.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Press Pull title test: read the drawn header from the node value as well as its label

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

---------

Co-authored-by: fork <fork@foo.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 06:04:14 +00:00
0rdnungsamt 98fdb6ef3c Tests: shader files keep LF line ends on Windows checkouts, so the GPU volume shader test passes there (#13138) (#13244)
* claim: #13138

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

* Shaders keep LF line ends on every checkout (#13138)

Tests embed .wgsl files with include_str! and match multi-line excerpts
written as Rust strings, which are always LF. On a Windows checkout with
core.autocrlf=true the shaders came out CRLF, so
gpu_volume_signed_primary_preserves_old_perspective_lens_none_and_zero_bytes
never matched. Mark *.wgsl as text with eol=lf, like requested_icons.txt.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:54:02 +00:00
github-actions[bot] 95fece4c7b Regenerate generated files and fold pending files [skip ci] 2026-11-08 06:20:59 +00:00
1993eg1 f959c0168d Add Hole command guide (machinist perspective) (#13125)
* Add Hole command guide (machinist perspective)

User guide for the Hole command: locating holes from two edges, UNC/UNF tapped holes, tap drill readout, blind tapped holes with a separate thread depth, and drill point angles. Tested in SolidCraft v0.30.1.

* Address review: reference edges, curved faces, multiple holes, presets

Updates from the CodeRabbit review, checked against the Hole dialog in SolidCraft v0.30.1:
- Reference edges must be straight and not parallel (no opposite sides).
- Single holes can go on curved faces; edge references need a flat face.
- Multiple placement: sketch points, set Placement to Multiple, select the points.
- Tapped holes carry the thread as a callout; the thread is not cut into the wall. Points to the Thread command for real thread geometry. No Modeled control appears in the v0.30.1 Hole dialog.
- Added a row for Personal Presets (Save new, Rename, Delete).
2026-11-08 05:42:38 +00:00
Kevin Ravensberg a2d42fdaec Render preview: near-black colours match the exported image (#5761) (#13219)
* Render preview: near-black colours decode as the exported image does

The product-lights branch of the viewport shader decoded the base colour
with `to_linear`, whose dark branch returns `c` instead of `c / 12.92`,
and on a linear attachment sent its encoded result back through
`out_color`, which decoded it the same way. Colours at or below about
#0a0a0a therefore lit far brighter in the Render preview than in the CPU
image (#050505: 38/255 against 5/255).

The branch now decodes with a piecewise `srgb_to_linear`, the transfer
`job::image` uses, returns linear light directly to a linear attachment
and encodes once otherwise. `to_linear` and the Design viewport's path
are unchanged.

The test-only offscreen renderer takes the preview lights, and a new test
renders a sphere under one uniform light for black, near-blacks on both
sides of the 0.04045 knee and mid-tones, on linear and sRGB attachments,
against the CPU image's decode, tone map and encode.

* Render preview: decode with the shader's existing srgb_decode

The product-lights branch now calls `srgb_decode`, the exact piecewise
decoder the sky fill already uses, instead of an identical new
`srgb_to_linear`.
2026-11-08 05:32:34 +00:00
Nathan Dane eaf8f27a7f Panels: show real Browser, History, Assistant and Render scene content (#11309) (#13199)
* claim: #11316

* Panels: support keyboard navigation and accessible controls (#11316)

* Panels: mount existing content in the shared layout

* Panels: fix validation imports and simplify host setup

* Panels: recover legacy sizes and preserve splitter controls

* Panels: confine legacy size recovery to shared import

* Assistant: keep the composer visible in short shared panels

* Assistant: reserve picker spacing above the composer

* Keep shared Assistant top controls within composer scroll budget
2026-11-08 05:28:07 +00:00
dudedagger42 8f1143b940 Naming: a piece name finds its face once the face is whole again (#11731) (#13147)
A reference picked while a face was split keeps the piece's qualifier
(F4:side:a1#[F4:start]). When that face is one face again, the whole face
still bounded by the faces the qualifier names now matches as the piece, so
the projected edge between it and F4:start is found. Numbered pieces stay
strict.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:22:09 +00:00
dudedagger42 778e7465e9 Circular Pattern of a cut works with another component active (#12996) (#13145)
* Add failing test: pattern of an untargeted cut with another component active (#12996)

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

* Pattern of an untargeted cut or intersect works on its source's component (#12996)

The copies took the bodies of the component active when the pattern was made,
so with another component active the pattern said there was no body to cut.

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

* Pattern: source-component scope only when the pattern's component has no body; also for misses and sheet cuts (#12996)

- The copies of an untargeted cut or intersect take the source's component only when the
  pattern's own component has no body (and no participants): a pattern that cut bodies of its own
  component before still does, so saved designs rebuild the same.
- That scope is chosen before the copy-miss count and the sheet-cut step, so all three use it.
- Test: pattern_of_an_untargeted_cut_still_cuts_its_own_components_body.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:21:01 +00:00
PeppaWutz fa127b3e29 Agents and scripts: the feature folder commands, Reorder, Move Timeline Items and Edit Feature describe their exact parameters and reply (#13131) (#13180)
timeline.group, ungroup, move_to_group, rename_group, reorder, reorder_many,
move_many and redefine now publish typed input and output schemas, so agents
and scripts can read exactly which parameters they take and what they return,
and the warn and strict parameter checks cover them.

Each parameter is read the way its handler reads it today, with the same error
messages, and the replies are built from the typed values and serialize to the
same JSON as before. Their lines leave unconverted.txt. Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:20:05 +00:00
CamWight 970eb715f3 Control channel: each step of engine.script gets the window's follow-up, like engine.batch (#12629) (#12630)
* claim: #12629

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

* Control channel: each step of engine.script runs as engine.execute runs it (#12629)

A script whose steps were all document commands ran them on the engine session alone, so the
window's part of each command was skipped: after `view.grid {"on": false}` the grid stayed on, and
the tool, the spin, the layout, preferences such as View Angles, and the first-body fit were missed
too. Now every step goes through `control::execute`, as each step of `engine.batch` does. The first
failing step still stops the script and the steps before it stay, with the same error words; a
script with a panel command answers as before.

The script's shape and its 100 000-step cap are read by one function, `script_steps`, for the
engine's own scripts and the window's.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:37 +00:00
Kai Evans a74e78fba8 Chamfer and fillet: an edge on a moved part is found where you picked it (#13239)
A chamfer or fillet without `body` on a moved part failed with
"[20, 0, 0] is where 2 edges meet". This happened when another part
had an edge at that point in its own frame. Take 2 parts that meet
in the world, but not in their own frames. A pick on the shared edge
cut the first part. edge_points looked for edges on every body in
that body's own frame. It ignored the world point of the pick.

- frames.rs remembers the world point of every pick, face or not.
  picked_world(local, nth) returns it. nth keeps 2 picks at one local
  point apart.
- edge_points maps the world point into each placed body's frame,
  hidden placements too. The world rows are built on first use. A
  body's box, grown by the reach, culls it. The polylines short-list
  edges. The exact curves decide (edge_foot: 33 samples, then a
  golden-section search). Bodies within 2 d + 1e-3 mm of the nearest
  refuse the pick. The reply names both: "[20, 0, 0] lies on edges of
  2 bodies, Base block and Cap block: name the one to chamfer with
  `body`". A hidden body adds "(X is hidden)".
- A seam at the pick, with no edge there, adds no second body.
- 2 placements of one body at one edge, or one local point, are 1 edge.
- With no placement to look through, the pick uses the bodies' own
  frames. That happens when every frame is singular. The pick is not
  passed unchecked.
- A pick's own `body` must exist and agree with `body`. Else the
  command refuses: "an edge names `Cap block`, but `body` is `Base
  block`: give one". Main finds the pick on Cap and cuts Base.
- The corner check runs on the body found. Corner and no-edge
  refusals print the world point.
- With 2 or more bodies, the feature keeps the body found as `body`.
  Picks on 2 or more bodies get 1 more check. If another
  body's frame has an edge at a pick's point, the command refuses. The
  reply asks for "each body in a command of its own". The ghost check
  skips edges whose polyline is too far before it runs edge_foot.
- solid.press_pull forwards `body` to the fillet. Its frames entry
  maps `body`, and its params name it.
- The fillet schema and the fillet and chamfer params describe the
  lookup.

Changes a default: no. Calls that cut the wrong part now refuse,
naming both parts. So do picks on the cut of a split body. A pick
whose own `body` disagrees with `body` refuses too. Dialogs already
send `body`.

Synthetic scripts ran against 6a1fe7468 (solidcraft-cli run,
--profile ci). 4 picks now cut the picked part, where main refused:
- Base's edge, with Cap moved 50 mm up.
- The same, with the root active and Cap moved 500 mm along x.
- Cap's edge at its world point.
- Base's edge with tangent_chain false.
Parts meeting in one frame or in the world are refused, naming
both. Press Pull there is refused too; main rounds Base block. A box
edge on a cylinder's seam is cut; main tries the seam and fails.

Tests: 19 in frames_tests.rs (16) and tests.rs (3). 16 fail on
6a1fe7468. 3 pass there: hostile picks, a singular placement, and 2
placements of one part. action_matrix
edge_round_actions_keep_the_picked_body expects the new refusal.

cargo xtask ci: 13948 passed, 32 failed, 67 ignored, 89 binaries.
- 22 failures are in tests/KNOWN_FAILING_TESTS.
- 7 fail alone on 6a1fe7468 too:
  3 pleat tests (FilletRearOuter, SMLib status 1001),
  smlib a_cylinder_side_section_is_its_circle,
  gpu gpu_thin_lens_focus_and_defocus_match_geometric_oracle,
  ui-egui the_extrude_arrow_passes_faces_beside_the_profile_and_in_a_free_drag,
  kernel boolean_props translation_invariant (a random 2-sphere case).
- 3 ui-egui agent_chat tests pass alone on both trees.
2026-11-08 05:19:04 +00:00
0rdnungsamt 2171393816 Section Analysis: the ground shadow stays soft instead of ending in a hard line at the section plane (#10708) (#12959)
* claim: #10708

* Section Analysis: the ground shadow is no longer cut by the section plane (#10708)

The ground shadow was drawn with the see-through pipeline, whose fragment
shader discards everything past the section plane, so a section ended the
soft shadow in a hard straight line. It now uses the pipeline reference
planes already use, the same blend and depth test without the cut.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:04 +00:00
PeppaWutz 3eacb3722e Agents and scripts: the occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and Paste New describe their exact parameters and reply (#13186) (#13231)
* Agents and scripts: the occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and Paste New describe their exact parameters and reply (#13186)

component.capture_position, component.revert_position, component.move_bodies,
component.move_sketches, component.paste_new, occurrence.copy, occurrence.drag,
occurrence.ground, occurrence.move and occurrence.properties now publish typed
input and output schemas. Each parameter is read the way its handler reads it
today and refused with the handler's own message, keys a handler reads only in
some cases (for example component with occurrence, tolerance with matrix) are
checked only in those cases, and the replies are built from the same types the
output schemas describe, so the JSON they return does not change. Their lines
leave unconverted.txt.

The base_transform check of occurrence.move is split out of the handler, and
move_vector and the matrix readers are made visible to the schema module, so
the contract uses the handler's own readers without changing what they accept.

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.

* Schema: base_transform is 4 columns of 4 numbers, as the handler reads it

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 05:19:02 +00:00
PeppaWutz 2c82e702f5 Agents and scripts: Joint Origin, Rigid Group, Reach Point, Motion Link, Gear, Rack and Pinion, Screw and Cam describe their exact parameters and reply (#13185) (#13242)
joint.origin, joint.rigid_group, joint.reach, joint.motion_link, joint.gear,
joint.rack_pinion, joint.screw and joint.cam now publish typed input and
output schemas. Each parameter is read the way its handler reads it today and
refused with the handler's own message. Keys that a handler reads only in some
cases (for example a joint origin's end, or a screw's a and b) are read only in
those cases. The replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt. Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:18:21 +00:00
PeppaWutz f256bef34b Agents and scripts: Create Sketch, Normal/Construction, Delete, Drag, Move Point, Move Dimension Text, Sketch Options, Chain and Solve describe their exact parameters and reply (#13224) (#13273)
sketch.create, sketch.construction, sketch.delete, sketch.move_point,
sketch.drag, sketch.drag_end, sketch.dimension_text, sketch.options,
sketch.chain and sketch.solve now publish typed input and output schemas.
Each parameter is read the way its handler reads it today, in the cases it
reads it, and refused with the handler's own message. Each reply is built
from the typed value and serializes to the same JSON as before. Their lines
leave unconverted.txt.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:18:14 +00:00
ABDULLAH 83cb509f81 Arabic: tanween in the canvas lock message and path tracing help, sketch term in the double Esc preference and Concentric help, timeline term in the Features under bodies note, Group holes preference wording (#13123)
* Arabic: tanween in the canvas lock message and path tracing help, sketch term in the double Esc preference and Concentric help

* Arabic: timeline term in the Features under bodies note

* Arabic: created-holes wording and collapsed-folder term in the Group holes preference
2026-11-08 05:17:41 +00:00
PeppaWutz c18aaf7cc3 Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508) (#12601)
* Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508)

sketch.insert_dxf, sketch.insert_svg and sketch.export_dxf now publish typed
input and output schemas. Each parameter is read the way its handler reads
it today and refused with the handler's own message, and the replies are
built from the same types the output schemas describe, so the JSON they
return does not change. Keys the handler reads only in some cases (a path
next to text, a height next to a width, a scale next to either) are read by
the check in the same cases only. Their lines leave unconverted.txt.

Part of #2829.

* Insert DXF and SVG: the schema refuses a blank or too-long path when it is read

* Insert DXF and SVG: the schema checks height, scale and path only when they are read

The published schema constrained `height` next to a `width`, `scale` next to
either, and `path` next to a `text`, which the handler never reads. Their shapes
now apply only in the cases the handler reads them, matching `leave_unread`.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 02:38:51 +00:00
bherbruck 1e6b1ec20f STEP import: a surface model of open shells, some closed, beside a turned sheet imports again (#13226)
A SolidWorks 2011 vendor part (a SHELL_BASED_SURFACE_MODEL of three OPEN_SHELLs, two of
them closed, the third using faces against their surfaces) came in as nothing on the SMLib
kernel: "STEP import: no bodies (... not supported yet: SMLib renumbered the solid's faces)".
SMLib's export numbers such a body's edges and vertices otherwise than the reader, so the
face map was refused even though the faces were in the reader's order. Where the full
numbering does not match, the faces are now taken in the record's order when each one is
checked to be the record's face (same surface kind, loops, and corners).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:55:43 +00:00
PeppaWutz 4d96c780e1 Agents and scripts: the sketch projection commands describe their exact parameters and reply (#12505) (#12598)
* Agents and scripts: the sketch projection commands describe their exact parameters and reply (#12505)

sketch.project, intersect, include_3d, project_to_surface,
intersection_curve, spun_profile, isoparametric_curve,
fit_curves_to_section, break_link and auto_project now publish 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 replies are built
from the same types the output schemas describe, so the JSON they return
does not change. Their lines leave unconverted.txt.

The shape check of a reference is split out of the handler's reference
reader so the contract can use it without changing what it accepts.

Part of #2829.

* Sort the schema module list for rustfmt

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 01:45:28 +00:00
github-actions[bot] 52afe212a8 Regenerate generated files and fold pending files [skip ci] 2026-11-08 01:37:34 +00:00
Daniel Dimitrov 0bb09d0b13 Hole: an option to put the holes one Hole command makes into a History folder (#12492) (#12493)
* Hole: an option to put the holes one Hole command makes into a folder (#12492)

Preferences > Tools > Hole > Group holes made together (hole_group, off by
default). With it on, when one Hole OK makes two or more holes, they go
into a feature folder named "Holes" in History, closed at first; opened,
each hole is edited on its own as before. A lone hole and an edit make no
folder, and holes added inside a folder get their folder there.

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

* Hole: a new Holes folder starts folded through the History's fold state

Since the History keeps each design's folds (#8886), fold the new folder
with timeline_folders::set_open instead of the browser's collapsed set.

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

* Hole: add several placements on one face

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:59:31 +00:00
PaddleStroke 11abdba1ba Export test: a box exports as its grid mesh (124 triangles), not 12 (#12781)
* Export test: count the box's grid mesh, not 12 triangles

each_format_has_an_export_command_of_its_own asserted a 20 x 10 x 5 box
exports as 12 triangles (an 684-byte STL). The mesher lays a planar face
out as a regular grid with sides at most a fifth of the body's diagonal
(kernel test a_box_is_a_regular_grid_on_every_face), so the box is 124
triangles and the STL 6284 bytes, on Linux and Windows alike. The test now
takes the count from the body's export mesh and checks the STL header,
the file size and the OBJ's faces against it; its line leaves
tests/KNOWN_FAILING_TESTS.

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

* Export test: expect the box's grid count, worked out from the mesher's rule

Review: a count taken from export_mesh would follow an export that drops
triangles. The test now expects 124 from the grid rule (cells at most a
fifth of the diagonal: 5 x 3 x 2 along 20 x 10 x 5).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:35:56 +00:00
PeppaWutz 7c9c1699de Agents and scripts: Inspect Document, Measure, Inspect Sketch, the command list and the material commands describe their exact parameters and reply (#12498) (#12591)
document.inspect, engine.commands, inspect.measure, material.assign,
material.list and sketch.inspect now publish typed input and output schemas.
Each parameter is read the way its handler reads it today, and is refused with
the handler's own message; inspect.measure reads only `items` when it is given
and `bodies` and `names_only` otherwise. The replies are built from the same
types the output schemas describe, so the JSON they return does not change.
Their lines leave unconverted.txt.

Part of #2829.
2026-11-08 00:04:33 +00:00
PeppaWutz cf9ae4d25e Agents and scripts: Auto Constrain, the constrainer, snapping and the curvature and polygon constraints describe their exact parameters and reply (#12506) (#12599)
* Agents and scripts: Auto Constrain, the constrainer, snapping and the curvature and polygon constraints describe their exact parameters and reply (#12506)

sketch.auto_constrain, sketch.auto_constrain_from_datum,
sketch.finish_auto_constrain, sketch.constrainer, sketch.constraint.curvature,
sketch.constraint.polygon, sketch.toggle_driven, sketch.glyphs and sketch.snap
now publish typed input and output schemas, so agents and scripts can read
exactly which parameters they take and what they return, and the warn and
strict parameter checks cover them.

Each parameter is read the way its handler reads it today, with the same error
messages. The replies are built from typed values and serialize to the same
JSON as before. Their lines leave unconverted.txt. Part of #2829.

* Drop constraint_result, which no command uses any more

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

* Check the coincident snap's point id in the reply test

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 00:04:16 +00:00
PeppaWutz 1f29f432ee Agents and scripts: the 3D sketch commands describe their exact parameters and reply (#12507) (#12600)
sketch.line3d, sketch.spline3d, sketch.point3d and sketch.move3d now
publish 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 replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-08 00:04:04 +00:00
PeppaWutz dc679cae34 Agents and scripts: the parameter commands and Evaluate Expression describe their exact parameters and reply (#12497) (#12590)
parameters.change, list, users, graph, export, import, complete and
expr.evaluate now publish typed input and output schemas. Each parameter is
read as its handler reads it today, with the same error messages; keys a
handler only reads in some forms (changes, delete, favorites, text, a text
unit) are checked only in those forms. Each reply is built from the typed value
and serializes to the same JSON as before. Their lines leave unconverted.txt.
2026-11-07 22:46:30 +00:00
PeppaWutz dbbfa437a5 Agents and scripts: the tangent arcs and circles, slots, splines, conics, ellipses and sketch text describe their exact parameters and reply (#12550) (#12604)
sketch.arc.tangent, sketch.blend_curve, sketch.centerline,
sketch.circle.two_tangent, sketch.circle.three_tangent, sketch.conic,
sketch.edit_text, sketch.ellipse, sketch.infinite, sketch.line.midpoint,
the three slot commands that were missing, the three spline commands
and sketch.text now publish typed input and output schemas.

Each parameter is read the way its handler reads it today and refused
with the handler's own message. Keys a handler reads only in some cases
(diameter without a radius, end without a sweep, minor without a minor
radius, the keys of one text outside edits) are checked only in those
cases. The replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-07 22:46:03 +00:00
bherbruck ba43d2798f Docs: RFCs 0001 (Craft bridge) and 0002 (plugins) (#13194)
* docs: RFC 0001, the Craft bridge between SolidCraft, CamCraft and PCBCraft

Starts docs/rfcs/ for cross-app proposals. RFC 0001 is a draft: one
.craftx exchange file with stable face names, an open-in launch, file
watching for live links, and a phased plan starting with Send to CamCraft.

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

* docs: RFC 0002, plugins

Turns the design in #1221 and docs/plugins.md into an RFC. docs/plugins.md
becomes a stub pointing to it; the two links to it are updated.

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

* docs: keep the RFC change docs-only

docs/plugins.md stays as a pointer to the RFC, so the code comment needs no edit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:05:23 +00:00
MarkusRe 6a1fe74683 Sketch Tangent: a line's end point and a curve make a tangent joint (#12092) (#12377)
* claim: #12092

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

* Sketch Tangent: a line's end point and a curve make a tangent joint (#12092)

Tangent took two curves only and refused a picked end point with
"unknown sketch curve l1.end". It now takes a line's or an arc's end
point with a curve: the end lies on the curve (point on curve) and the
two curves are tangent there, in one undo step. Two end points make the
ends coincident and the curves tangent. Only the picked end moves, so
the line turns about its other end and gets longer or shorter; the
other curve stays.

Parallel, Perpendicular, Equal and Collinear take no point, so a click
on a line's end point now picks the line for them.

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

* Sketch Tangent: at a joint, add only the tangent (#12092)

When the picked end already lies on the other curve (it is that
curve's end, or a coincident joins them), Tangent added a redundant
point-on-curve, and a coincident given in the other order came back as
the existing constraint but was reported as new. Now such a constraint
counts as held and is not added. The end can't leave the other curve
at a joint, so the curve it ends stays and the other curve moves.

Raised in review by CodeRabbit.

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

* Sketch Tangent: x and y name the axes before a point does (#12092)

Tangent looks for an end point only when an operand names no curve and
no sketch axis. It checked for an existing axis line, so before that
line was created a point called x or y took the axis's place. Now x and
y always name the axes, as for the other constraints that take a line;
a curve with that id still wins.

Raised in review by CodeRabbit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:08:10 +00:00
github-actions[bot] a9ff55ef27 Regenerate generated files and fold pending files [skip ci] 2026-11-07 22:31:45 +00:00
github-actions[bot] 3081630f92 Regenerate generated files and fold pending files [skip ci] 2026-11-07 22:17:47 +00:00
Daniel Dimitrov 323defa525 History: an option for a folder of one kind of feature to show that kind's icon (#12514) (#12515)
* History: an option for a folder of one kind of feature to show its icon (#12514)

Preferences > Layout > Folder icon from its features
(history_folder_kind_icon, off by default). With it on, a History folder
whose features (including those in folders inside it) all have the same
icon, and that holds no joints, shows that icon instead of the folder
icon, keeping its count; in both the History bar and the side History.

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

* Preferences: Folder icon from its features in a row of its own

One more box in the row of History and browser options widened the
Layout page past its edge at narrow windows, pushing the navigation
bar's segments outside it
(navigation_bar_appearance_segments_stay_inside_the_page_at_narrow_widths).
Also give the new messages in Korean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:39:38 +00:00
PeppaWutz 5eaf8c4181 Agents and scripts: the named view commands and Redefine Sketch describe their exact parameters and reply (#12499) (#12592)
view.save, view.apply, view.rename, view.delete, view.list and
sketch.redefine now publish 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 replies are built from the same
types the output schemas describe, so the JSON they return does not
change. view.apply only looks at the camera when no view name is given,
as the handler does. Their lines leave unconverted.txt.

The camera checks of view.save and view.apply, and the note that
sketch.redefine adds for a world plane in a moved component, are split
out of the handlers so the contracts use the same code.

Part of #2829.
2026-11-07 19:58:45 +00:00
PeppaWutz b464c48506 Agents and scripts: Frame Member and the profile library commands describe their exact parameters and reply (#12500) (#12593)
frame.member, frame.profile.insert, frame.profile.list and
frame.profile.properties now publish typed input and output schemas. Each
parameter is read the way its handler reads it today and refused with the
handler's own message, and frame.member reads the profile-sketch keys only
when the handler does. The replies are built from the same types the output
schemas describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-07 19:58:36 +00:00
PeppaWutz 7a2f2949a7 Agents and scripts: Copy and Paste Features, Add Recorded Feature and Export Command Script describe their exact parameters and reply (#12502) (#12595)
timeline.copy, timeline.paste, timeline.add and timeline.export_script now
publish typed input and output schemas, so agents and scripts can read
exactly which parameters they take and what they return, and the warn and
strict parameter checks cover them.

Each parameter is read the way the handler reads it today, with the same
error messages, and the replies are built from typed values that serialize
to the same JSON as before. Their lines leave unconverted.txt. Part of #2829.
2026-11-07 19:58:27 +00:00
PeppaWutz fcd4ccd09a Agents and scripts: the component commands describe their exact parameters and reply (#12504) (#12597)
component.activate, create, delete, from_bodies, list, properties, rename
and unlock now publish typed input and output schemas. Each parameter is
read the way its handler reads it today and refused with the handler's own
message, and each reply is built from the typed value and serializes to the
same JSON as before. Their lines leave unconverted.txt. Part of #2829.
2026-11-07 19:58:18 +00:00
PeppaWutz beaa43cb40 Agents and scripts: Create, Delete, List, Inspect, Limits, Drive, Solve and As-Built Joint describe their exact parameters and reply (#12503) (#12596)
* Agents and scripts: Create, Delete, List, Inspect, Limits, Drive, Solve and As-Built Joint describe their exact parameters and reply (#12503)

joint.as_built, joint.create, joint.delete, joint.drive, joint.inspect,
joint.limits, joint.list and joint.solve now publish typed input and output
schemas. Each parameter is read as its handler reads it today, with the same
error messages, in the same modes, and each reply is built from the typed
value and serializes to the same JSON as before. Their lines leave
unconverted.txt. Part of #2829.

* Joints: a joint origin's points take {x, y, z} in the schema too, as the handler reads them
2026-11-07 19:41:10 +00:00
PeppaWutz 6085423d0d Agents and scripts: the exploded view commands describe their exact parameters and reply (#12501) (#12594)
explode.create, explode.delete, explode.list and explode.show now publish
typed input and output schemas. Each parameter is read as its handler reads
it today, with the same error messages (scale is only read when moves is
not given, and show treats a missing or null view as "none"), and each reply
is built from the typed value and serializes to the same JSON as before.
Their lines leave unconverted.txt.
2026-11-07 19:23:14 +00:00
Loohi 4425b87d2b Typed schemas for Line, Point, Circle and Arc (#12953) (#12955)
* claim: #12953

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

* Typed schemas for Line, Point, Circle and Arc (#12953)

sketch.line, sketch.point, sketch.circle.center, sketch.circle.two_point,
sketch.circle.three_point, sketch.arc.three_point and sketch.arc.center_point
get typed input contracts and output schemas in schema/sketch_shapes.rs, and
leave unconverted.txt. The replies keep the same JSON.

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

* Blend test: give the chord line its id with `ids`

sketch.line takes `ids`; the `id` it was given was ignored, which the
parameter audit flags as a call strict would refuse.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:46:23 +00:00
filippecha 59d032659f Czech: translate the 748 missing UI strings, so the Czech interface has no English left (#12961)
* Czech: translate the 748 missing UI strings and fix 3 untranslated ones

The Czech catalog now covers every English message. New translations
go in assets/locales/pending/czech-complete.json; three messages that
were left in English in cs.json (Zoom, Polygon, Start) are translated
in place.

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

* Czech: name the render methods like their labels (Trasování paprsků CPU, Trasování cest GPU) and fix the direction of the graphics page subtitle

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:13:31 +00:00
Albert Shambazov 395679015f IO: sketch.export_dxf - units, layers and joined LWPOLYLINE loops (#12725) (#12726)
Opt-in options for sketch.export_dxf: units (scale + $INSUNITS), layer /
layers:"roles" (OUTLINE / CUT), join:true (closed LWPOLYLINE with arc
bulges, R2000). Default output stays byte-identical R12.

Co-authored-by: Albert Shambazov <319170918+shambazovalbert-art@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:48:25 +00:00
PaddleStroke 30be21a224 Browser: list the features that built each body under it, as an option (#11945) (#12709)
* claim: #11945

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

* wip: body features data, model.body_features and the Browser's Features under bodies (#11945)

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

* wip: fmt; engine body features test passes (#11945)

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

* kernel-cases: model.body_features is a listing with no kernel case

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

* Browser: a feature listed under two bodies is a Shift-click range end at each of its rows (#11945)

Each feature row under a body takes its own row-order key, <body row>/s:<id>, which still selects the feature, so a Shift-click on its second row no longer starts the range at its first row under another body.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:46:29 +00:00
560 changed files with 48489 additions and 12026 deletions
+3
View File
@@ -1,6 +1,9 @@
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
.gitattributes text eol=lf
/assets/icons/ui/design/requested_icons.txt text eol=lf
# Shaders reach the code through include_str!, and tests match multi-line WGSL excerpts
# written as Rust strings (always LF), so a CRLF checkout must not change them (#13138).
*.wgsl text eol=lf
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
*.sc -linguist-detectable
+2 -1
View File
@@ -201,7 +201,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
@@ -842,6 +841,8 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
| assets/locales/pending/press-pull-dialog-title.json | SolidCraft contributors | Original Press Pull dialog title translations for SolidCraft's English catalog; no external text used | MIT OR Apache-2.0 |
## Code adapted from sibling projects
Generated
+77 -3
View File
@@ -4075,7 +4075,6 @@ dependencies = [
"eframe",
"egui",
"embed-resource",
"getrandom 0.3.4",
"hidapi",
"keyring",
"libc",
@@ -4093,10 +4092,13 @@ dependencies = [
"serde",
"serde_json",
"solidcraft-3dx-client",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-theme",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"ureq",
"windows-sys 0.61.2",
"winit",
@@ -4123,6 +4125,29 @@ dependencies = [
"solidcraft-mcp",
]
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-control"
version = "0.40.0-rc.3"
dependencies = [
"getrandom 0.3.4",
"serde",
"serde_json",
]
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"image",
"serde_json",
"solidcraft-control",
]
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.3"
@@ -4141,6 +4166,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4173,6 +4199,8 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"solidcore-record",
"solidcraft-color",
"solidcraft-control",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
@@ -4207,6 +4235,7 @@ dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcraft-color",
"solidcraft-engine",
"solidcraft-geom",
"solidcraft-io",
@@ -4234,6 +4263,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4268,8 +4298,17 @@ version = "0.40.0-rc.3"
dependencies = [
"image",
"serde_json",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-mcp-server",
]
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
]
[[package]]
@@ -4293,6 +4332,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-geom",
"web-time",
"weezl",
@@ -4345,6 +4385,16 @@ dependencies = [
name = "solidcraft-testkit"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"serde",
"serde_json",
"solidcraft-color",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.3"
@@ -4355,6 +4405,7 @@ dependencies = [
"image",
"log",
"minisign-verify",
"muda",
"nix 0.31.3",
"pollster 0.4.0",
"proc-macro2",
@@ -4363,20 +4414,41 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-control",
"solidcraft-control-egui",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-theme",
"solidcraft-ui-registry",
"solidcraft-ui-text",
"syn 2.0.119",
"sys-locale",
"tiny-skia",
"toml",
"ttf-parser",
"unicode-bidi",
"web-time",
"winreg",
"zip",
]
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde_json",
"sys-locale",
"unicode-bidi",
]
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.3"
@@ -4387,8 +4459,10 @@ dependencies = [
"log",
"percent-encoding",
"serde_json",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+7
View File
@@ -28,8 +28,15 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-color = { path = "crates/color" }
solidcraft-ui-text = { path = "crates/ui-text" }
solidcraft-theme = { path = "crates/theme" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-mcp-server = { path = "crates/mcp-server" }
solidcraft-control = { path = "crates/control" }
solidcraft-control-egui = { path = "crates/control-egui" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
+2 -1
View File
@@ -68,7 +68,8 @@ impl Control {
if !addr.ip().is_loopback() || addr.port() == 0 {
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
}
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
let token = solidcraft_engine::control_auth::APP
.client_token(addr.port(), token)
.map(|t| t.token)
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
if token.len() > 4096 {
+1 -1
View File
@@ -916,7 +916,7 @@ fn serve_mcp(
/// The port of the running desktop app: the one its discovery file names, if something listens
/// there (a file left by an app that crashed names a closed port).
fn discover_app() -> Option<u16> {
let port = solidcraft_engine::control_auth::read_current_port()?;
let port = solidcraft_engine::control_auth::APP.read_current_port()?;
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
}
+1 -1
View File
@@ -1308,7 +1308,7 @@ fn edge_round_actions_keep_the_picked_body() {
let mut unscoped = p.clone();
unscoped.as_object_mut().unwrap().remove("body");
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
assert!(err.contains("edges meet"), "{err}");
assert!(err.contains("[10, 0, 20] lies on edges of 2 bodies, Plate and Block"), "{err}");
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
tried += 1;
}
+2
View File
@@ -22,7 +22,9 @@ profile = ["solidcraft-engine/profile"]
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_ui_egui::ControlRequest;
use solidcraft_control::ControlRequest;
use wasm_bindgen::{JsValue, closure::Closure};
const MAX_PENDING: usize = 256;
+4 -4
View File
@@ -48,15 +48,15 @@ impl Download {
}
if solidcraft_ui_egui::documents::any_dirty(app) {
let mut answer = None;
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
.required()
.show_body(ctx, |ui| {
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
ui.horizontal(|ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
answer = Some(true);
}
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
answer = Some(false);
}
});
+9 -8
View File
@@ -810,7 +810,7 @@ mod web {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -864,7 +864,7 @@ mod web {
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
@@ -1016,7 +1016,7 @@ mod web {
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
@@ -1058,7 +1058,7 @@ mod web {
false
}
};
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
@@ -1067,13 +1067,13 @@ mod web {
ctx.request_repaint();
}
let ready = self.script_font_ready();
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
self.apply_fonts(ctx);
}
}
@@ -1081,7 +1081,7 @@ mod web {
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
@@ -1149,7 +1149,7 @@ mod web {
UNSAVED.with(|u| u.set(self.asks_before_unload()));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
{
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
@@ -1225,6 +1225,7 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
solidcraft_ui_egui::i18n::install();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
let launch = crate::Launch::parse(&query());
+3 -1
View File
@@ -31,9 +31,12 @@ neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
solidcraft-gpu = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
solidcraft-theme = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
@@ -45,7 +48,6 @@ rfd = { workspace = true }
ron = "0.12"
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
pollster = { workspace = true }
getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
File diff suppressed because it is too large Load Diff
+6 -15
View File
@@ -35,7 +35,7 @@ mod native_crash;
mod native_crash_macos;
#[cfg(target_os = "windows")]
mod native_frame;
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", test))]
mod native_menu;
mod nav_bridge;
#[cfg(target_os = "macos")]
@@ -468,7 +468,7 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
let content = ui.ctx().content_rect();
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
ui.painter().rect_filled(above, 0.0, solidcraft_theme::Tokens::get().app_bar);
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
}
@@ -518,7 +518,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
// The groups come from the format registry, so a new format joins this dialog by itself.
let mut everything = io::formats::open_extensions(true);
everything.extend(["dxf", "svg"]);
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
@@ -913,17 +913,7 @@ fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::
#[cfg(not(target_os = "macos"))]
let icon =
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
Ok(egui::ViewportBuilder::default()
.with_title("SolidCraft")
.with_icon(icon)
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID))
Ok(solidcraft_ui_egui::window_chrome::viewport("SolidCraft", APP_ID, custom_titlebar).with_icon(icon))
}
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
@@ -945,6 +935,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
solidcraft_ui_egui::i18n::install();
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
@@ -1221,7 +1212,7 @@ fn main() -> eframe::Result {
mark("viewport pipelines");
// `--control`/the environment name a port; otherwise the preference (on by default)
// opens the default one (docs/control-protocol.md).
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
app = app.with_control(rx);
}
+347 -162
View File
@@ -1,137 +1,221 @@
//! macOS system menus, owned and updated on eframe's main thread.
//! SolidCraft's macOS menu bar: its entries as kit menu items
//! (`solidcraft_ui_egui::window_chrome::native_menu`), each with a command id or a `ui.*` id, run
//! through the same actions as the application bar's File menu (`toolbar::FileAction`).
//!
//! Entries that are control actions use their ids and do the same there: `app.quit`,
//! `ui.prefs {open}` (Settings and Keyboard Shortcuts), `ui.agent_chat {toggle}`, `ui.help {item}`. Undo and Redo are the commands
//! `edit.undo` and `edit.redo`. The other entries' ids are defined here: `ui.file.new`,
//! `ui.file.open`, `ui.file.open_recent {path}`, `ui.file.clear_recent`, `ui.file.save`,
//! `ui.file.save_as`, `ui.file.save_json`, `ui.file.export {format}`,
//! `ui.file.export_mesh {format}`. Menus are `menu.file`, `menu.file.recent`, `menu.edit`,
//! `menu.window` and `menu.help`.
//!
//! The menu bar itself is macOS's; the entries and their ids build everywhere, so their tests run
//! on every system.
use std::sync::mpsc::{Receiver, sync_channel};
use muda::{
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, Code, Modifiers},
use serde_json::{Value, json};
use solidcraft_ui_egui::{
SolveApp,
toolbar::FileAction,
ui_kit::Item,
window_chrome::native_menu::{self, MenuBar},
};
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
/// The Open Recent submenu, refilled when the recent designs change.
const RECENT: &str = "menu.file.recent";
const OPEN_RECENT: &str = "ui.file.open_recent";
/// Recent designs listed.
#[cfg(target_os = "macos")]
const RECENT_COUNT: usize = 32;
/// File > Insert entries.
const INSERTS: [(&str, FileAction); 3] = [
("Insert STEP\u{2026}", FileAction::Command("file.insert_step")),
("Insert Mesh\u{2026}", FileAction::Command("file.insert_mesh")),
("Insert Design", FileAction::Command("component.insert_design")),
];
/// File > Export entries.
const EXPORTS: [(&str, FileAction); 7] = [
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
("Export STEP\u{2026}", FileAction::Export("step")),
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
("Export OBJ\u{2026}", FileAction::Export("obj")),
("Export URDF\u{2026}", FileAction::Export("urdf")),
("Export Design as JSON\u{2026}", FileAction::Export("json")),
];
const DESIGN_PROPERTIES: FileAction = FileAction::Command("document.metadata");
/// The other actions with a fixed id (all but Open Recent), to map an id back to its action.
const FIXED: &[FileAction] = &[
FileAction::Settings,
FileAction::Shortcuts,
FileAction::Quit,
FileAction::New,
FileAction::Open,
FileAction::ClearRecent,
FileAction::Save,
FileAction::SaveAs,
FileAction::SaveJson,
DESIGN_PROPERTIES,
FileAction::Undo,
FileAction::Redo,
FileAction::AgentChat,
];
/// The Help menu's entries: the control channel's `ui.help {item}`.
const HELP_ID: &str = "ui.help";
/// The Help menu's items (`help::run`), top to bottom.
const HELP: [&str; 7] = ["docs", "updates", "shortcuts", "messages", "report", "crashes", "install_cli"];
fn help_label<'a>(app: &'a SolveApp, item: &str) -> &'a str {
match item {
"docs" => app.tr_label("Documentation"),
"updates" => app.tr("update.check_for_updates"),
"shortcuts" => app.tr_label("Keyboard Shortcuts"),
"messages" => app.tr_label("Messages\u{2026}"),
"report" => app.tr_label("Report an Issue\u{2026}"),
"crashes" => app.tr_label("Crash Reports\u{2026}"),
_ => app.tr_label("Install solidcraft-cli command"),
}
}
/// What a menu entry runs.
#[derive(Debug, PartialEq)]
enum Entry {
File(FileAction),
Help(&'static str),
}
/// The entry's id and parameters for `action`.
fn id_of(action: &FileAction) -> (String, Option<Value>) {
let fixed = |id: &str| (id.to_string(), None);
match action {
FileAction::Settings => ("ui.prefs".into(), Some(json!({ "open": "General" }))),
FileAction::Shortcuts => ("ui.prefs".into(), Some(json!({ "open": "Shortcuts" }))),
FileAction::Quit => fixed("app.quit"),
FileAction::New => fixed("ui.file.new"),
FileAction::Open => fixed("ui.file.open"),
FileAction::OpenRecent(path) => (OPEN_RECENT.into(), Some(json!({ "path": path }))),
FileAction::ClearRecent => fixed("ui.file.clear_recent"),
FileAction::Save => fixed("ui.file.save"),
FileAction::SaveAs => fixed("ui.file.save_as"),
FileAction::SaveJson => fixed("ui.file.save_json"),
FileAction::Export(format) => ("ui.file.export".into(), Some(json!({ "format": format }))),
FileAction::ExportMesh(format) => ("ui.file.export_mesh".into(), Some(json!({ "format": format }))),
FileAction::Command(command) => fixed(command),
FileAction::Undo => fixed("edit.undo"),
FileAction::Redo => fixed("edit.redo"),
FileAction::AgentChat => ("ui.agent_chat".into(), Some(json!({ "toggle": true }))),
}
}
/// The entry with `id` and `params`, `None` for one this menu doesn't have.
fn entry_of(id: &str, params: Option<&Value>) -> Option<Entry> {
if id == OPEN_RECENT {
return params.and_then(|p| p.get("path")).and_then(Value::as_str).map(|path| Entry::File(FileAction::OpenRecent(path.to_string())));
}
if id == HELP_ID {
let item = params.and_then(|p| p.get("item")).and_then(Value::as_str);
return HELP.into_iter().find(|h| Some(*h) == item).map(Entry::Help);
}
let wanted = (id.to_string(), params.cloned());
FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).find(|a| id_of(a) == wanted).map(Entry::File)
}
/// The entries with a key, as menu accelerators.
const SHORTCUTS: [(FileAction, &str); 8] = [
(FileAction::Settings, "Cmd+,"),
(FileAction::Quit, "Cmd+Q"),
(FileAction::New, "Cmd+N"),
(FileAction::Open, "Cmd+O"),
(FileAction::Save, "Cmd+S"),
(FileAction::SaveAs, "Cmd+Shift+S"),
(FileAction::Undo, "Cmd+Z"),
(FileAction::Redo, "Cmd+Shift+Z"),
];
fn item(label: &str, action: &FileAction) -> Item {
let (id, params) = id_of(action);
let shortcut = SHORTCUTS.iter().find(|(a, _)| a == action).map_or("", |(_, k)| *k);
let mut item = Item::action(&id, label, "").key(shortcut);
item.params = params;
item
}
fn submenu(id: &str, label: &str, children: Vec<Item>) -> Item {
let mut menu = Item::action(id, label, "");
menu.children = children;
menu
}
fn recent_items(app: &SolveApp, paths: &[String]) -> Vec<Item> {
let mut items: Vec<Item> = paths.iter().map(|path| item(path, &FileAction::OpenRecent(path.clone()))).collect();
items.push(Item::sep());
items.push(item(app.tr("ui.clear_recent"), &FileAction::ClearRecent));
items
}
fn menu_bar(app: &SolveApp, recent: &[String]) -> MenuBar {
let more = |key: &str| format!("{}\u{2026}", app.tr(key));
let mut file = vec![
item(app.tr("ui.new_design"), &FileAction::New),
item(&more("ui.open"), &FileAction::Open),
submenu(RECENT, app.tr("ui.open_recent"), recent_items(app, recent)),
Item::sep(),
];
file.extend(INSERTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
file.extend([
Item::sep(),
item(app.tr("ui.save"), &FileAction::Save),
item(&more("ui.save_as"), &FileAction::SaveAs),
item(app.tr_label("Save As JSON\u{2026}"), &FileAction::SaveJson),
Item::sep(),
]);
file.extend(EXPORTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
file.extend([Item::sep(), item(app.tr_label("Design Properties"), &DESIGN_PROPERTIES)]);
let edit = vec![item(app.tr("ui.undo"), &FileAction::Undo), item(app.tr("ui.redo"), &FileAction::Redo)];
let window = vec![item(app.tr_label("Agent Chat"), &FileAction::AgentChat)];
let help = HELP.iter().map(|h| Item::action(HELP_ID, help_label(app, h), "").with(json!({ "item": h }))).collect();
MenuBar {
title: "SolidCraft".into(),
version: env!("CARGO_PKG_VERSION").into(),
app: vec![item(&more("ui.preferences"), &FileAction::Settings), item(&more("ui.keyboard_shortcuts"), &FileAction::Shortcuts)],
quit: item(&format!("{} SolidCraft", app.tr("ui.quit")), &FileAction::Quit),
menus: vec![
submenu("menu.file", app.tr("ui.file"), file),
submenu("menu.edit", app.tr_label("Edit"), edit),
submenu(native_menu::WINDOW, "Window", window),
submenu("menu.help", app.tr("ui.help"), help),
],
}
}
/// The menu bar, owned and updated on eframe's main thread.
#[cfg(target_os = "macos")]
pub struct NativeMenu {
menu: Menu,
actions: Vec<(MenuItem, FileAction)>,
help_actions: Vec<(MenuItem, &'static str)>,
events: Receiver<MenuEvent>,
recent: Submenu,
menu: native_menu::NativeMenu,
recent_paths: Vec<String>,
}
#[cfg(target_os = "macos")]
impl NativeMenu {
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
let menu = Menu::new();
let application = Submenu::new("SolidCraft", true);
let file = Submenu::new(app.tr("ui.file"), true);
let edit = Submenu::new(app.tr_label("Edit"), true);
let recent = Submenu::new(app.tr("ui.open_recent"), true);
let window = Submenu::new("Window", true);
let help = Submenu::new(app.tr("ui.help"), true);
let mut help_actions = Vec::new();
let mut actions = Vec::new();
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
parent.append(&item)?;
actions.push((item, action));
Ok(())
};
application.append(&PredefinedMenuItem::about(
Some("About SolidCraft"),
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
))?;
application.append(&PredefinedMenuItem::separator())?;
add(
&application,
&format!("{}\u{2026}", app.tr("ui.preferences")),
FileAction::Settings,
Some(Accelerator::new(Modifiers::META, Code::Comma)),
)?;
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
application.append(&PredefinedMenuItem::separator())?;
application.append(&PredefinedMenuItem::services(None))?;
application.append(&PredefinedMenuItem::separator())?;
application.append_items(&[
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
])?;
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
file.append(&recent)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, command) in
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
{
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
add(
&file,
&format!("{}\u{2026}", app.tr("ui.save_as")),
FileAction::SaveAs,
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
)?;
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, action) in [
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
("Export STEP\u{2026}", FileAction::Export("step")),
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
("Export OBJ\u{2026}", FileAction::Export("obj")),
("Export URDF\u{2026}", FileAction::Export("urdf")),
("Export Design as JSON\u{2026}", FileAction::Export("json")),
] {
add(&file, app.tr_label(label), action, None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
window.append(&PredefinedMenuItem::separator())?;
window.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::fullscreen(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
])?;
for (action, label) in [
("docs", "Documentation"),
("updates", app.tr("update.check_for_updates")),
("shortcuts", "Keyboard Shortcuts"),
("messages", "Messages\u{2026}"),
("report", "Report an Issue\u{2026}"),
("crashes", "Crash Reports\u{2026}"),
("install_cli", "Install solidcraft-cli command"),
] {
let item = MenuItem::new(app.tr_label(label), true, None);
help.append(&item)?;
help_actions.push((item, action));
}
menu.append_items(&[&application, &file, &edit, &window, &help])?;
let (sender, events) = sync_channel(64);
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.try_send(event);
ctx.request_repaint();
}));
menu.init_for_nsapp();
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
native.update_recent(app)?;
pub fn new(app: &SolveApp, ctx: &egui::Context) -> Result<Self, String> {
let recent_paths: Vec<String> = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
let menu = native_menu::NativeMenu::new(&menu_bar(app, &recent_paths), ctx)?;
let native = Self { menu, recent_paths };
native.update(app, ctx);
Ok(native)
}
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
for event in self.events.try_iter().take(64) {
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
solidcraft_ui_egui::help::run(app, ctx, action);
}
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
for (id, params) in self.menu.events() {
match entry_of(&id, params.as_ref()) {
Some(Entry::Help(item)) => solidcraft_ui_egui::help::run(app, ctx, item),
// A focused text field takes its own Undo and Redo.
Some(Entry::File(action @ (FileAction::Undo | FileAction::Redo))) if ctx.egui_wants_keyboard_input() => {
raw.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
@@ -139,58 +223,159 @@ impl NativeMenu {
repeat: false,
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
});
} else {
action.run(app);
}
Some(Entry::File(action)) => action.run(app),
None => log::warn!("native menu: no entry `{id}`"),
}
}
}
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
action.menu_enabled(app, ctx.egui_wants_keyboard_input())
fn update(&self, app: &SolveApp, ctx: &egui::Context) {
let focus = ctx.egui_wants_keyboard_input();
self.menu.update(|id, params| match entry_of(id, params) {
Some(Entry::File(action)) => action.menu_enabled(app, focus),
Some(Entry::Help(item)) => solidcraft_ui_egui::help::available(app, item),
None => false,
});
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
for (item, action) in &self.actions {
let enabled = Self::action_enabled(action, app, ctx);
if item.is_enabled() != enabled {
item.set_enabled(enabled);
if !self.recent_paths.iter().eq(app.home.recent.iter().take(RECENT_COUNT)) {
self.recent_paths = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
if let Err(error) = self.menu.replace(RECENT, &recent_items(app, &self.recent_paths)) {
app.set_status(format!("Recent designs menu: {error}"), true);
}
}
for (item, action) in &self.help_actions {
let enabled = solidcraft_ui_egui::help::available(app, action);
if item.is_enabled() != enabled {
item.set_enabled(enabled);
}
}
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
&& let Err(error) = self.update_recent(app)
{
app.set_status(format!("Recent designs menu: {error}"), true);
}
}
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
while self.recent.remove_at(0).is_some() {}
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
for path in &self.recent_paths {
let action = FileAction::OpenRecent(path.clone());
let item = MenuItem::new(path, action.enabled(app), None);
self.recent.append(&item)?;
self.actions.push((item, action));
}
self.recent.append(&PredefinedMenuItem::separator())?;
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
self.recent.append(&clear)?;
self.actions.push((clear, FileAction::ClearRecent));
Ok(())
self.update(app, ctx);
}
}
impl Drop for NativeMenu {
fn drop(&mut self) {
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
self.menu.remove_for_nsapp();
#[cfg(test)]
mod tests {
use super::*;
/// Every action the menu offers, Open Recent with a path.
fn actions() -> Vec<FileAction> {
let mut all: Vec<FileAction> = FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).collect();
all.push(FileAction::OpenRecent("/work/a.solidcraft".into()));
all
}
#[test]
fn every_action_and_help_item_round_trips_through_its_id() {
let mut seen = vec![];
for action in actions() {
let (id, params) = id_of(&action);
assert!(!seen.contains(&(id.clone(), params.clone())), "two actions share `{id}` {params:?}");
assert_eq!(entry_of(&id, params.as_ref()), Some(Entry::File(action)), "`{id}` {params:?}");
seen.push((id, params));
}
for h in HELP {
assert_eq!(entry_of(HELP_ID, Some(&json!({ "item": h }))), Some(Entry::Help(h)));
}
// The control channel's ids where it has one.
assert_eq!(id_of(&FileAction::Quit), ("app.quit".to_string(), None));
assert_eq!(id_of(&FileAction::Settings), ("ui.prefs".to_string(), Some(json!({"open": "General"}))));
assert_eq!(id_of(&FileAction::Shortcuts), ("ui.prefs".to_string(), Some(json!({"open": "Shortcuts"}))));
assert_eq!(id_of(&FileAction::AgentChat), ("ui.agent_chat".to_string(), Some(json!({"toggle": true}))));
assert_eq!(entry_of("ui.file.export", Some(&json!({"format": "step"}))), Some(Entry::File(FileAction::Export("step"))));
for (id, params) in [
("ui.file.export", None),
("ui.file.export", Some(json!({"format": "dwg"}))),
(HELP_ID, Some(json!({"item": "nothing"}))),
(HELP_ID, None),
("ui.prefs", None),
("solid.nothing", None),
(OPEN_RECENT, None),
(RECENT, None),
(native_menu::WINDOW, None),
] {
assert_eq!(entry_of(id, params.as_ref()), None, "{id} {params:?}");
}
}
#[test]
fn every_shortcut_is_one_actions_and_the_menu_shows_it() {
for (i, (action, key)) in SHORTCUTS.iter().enumerate() {
assert!(SHORTCUTS.iter().skip(i + 1).all(|(a, k)| a != action && k != key), "{action:?} {key}");
assert_eq!(item("label", action).shortcut, *key);
}
assert_eq!(item("label", &FileAction::SaveJson).shortcut, "");
}
#[cfg(target_os = "macos")]
#[test]
fn menu_shortcuts_parse_as_the_keys_they_were() {
use muda::accelerator::{Accelerator, Code, Modifiers};
let (cmd, shift) = (Modifiers::META, Modifiers::META | Modifiers::SHIFT);
let want = [
Accelerator::new(cmd, Code::Comma),
Accelerator::new(cmd, Code::KeyQ),
Accelerator::new(cmd, Code::KeyN),
Accelerator::new(cmd, Code::KeyO),
Accelerator::new(cmd, Code::KeyS),
Accelerator::new(shift, Code::KeyS),
Accelerator::new(cmd, Code::KeyZ),
Accelerator::new(shift, Code::KeyZ),
];
for ((action, key), want) in SHORTCUTS.iter().zip(want) {
assert_eq!(key.parse::<Accelerator>().ok(), Some(want), "{action:?}");
}
}
#[test]
fn every_entry_of_the_menu_bar_runs_its_action() {
fn leaves(items: &[Item], out: &mut Vec<Item>) {
for item in items {
if item.children.is_empty() {
out.push(item.clone());
} else {
leaves(&item.children, out);
}
}
}
let app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
let bar = menu_bar(&app, &["/work/a.solidcraft".to_string()]);
let mut entries = vec![];
leaves(&bar.app, &mut entries);
leaves(std::slice::from_ref(&bar.quit), &mut entries);
leaves(&bar.menus, &mut entries);
entries.retain(|i| i.id != "-");
// App menu 2 + Quit; File: New, Open, 1 recent, Clear, 3 inserts, Save, Save As, JSON,
// 7 exports, Properties; Edit 2; Window 1; Help 7.
assert_eq!(entries.len(), 3 + 4 + 3 + 3 + 7 + 1 + 2 + 1 + 7, "{:?}", entries.iter().map(|i| &i.id).collect::<Vec<_>>());
for item in &entries {
assert!(entry_of(&item.id, item.params.as_ref()).is_some(), "`{}` {:?} runs nothing", item.id, item.params);
}
assert!(bar.menus.iter().all(|m| m.id.starts_with("menu.")), "menu ids are never actions");
}
/// The entries whose ids are control actions do over the control channel what they do in
/// the menu.
#[test]
fn shared_ids_mean_the_same_over_the_control_channel() {
use solidcraft_ui_egui::scenario::Harness;
let mut h = Harness::new();
h.app.preferences.ai_features = true;
h.app.agent_chat.open = false;
let (id, params) = id_of(&FileAction::AgentChat);
for open in [true, false] {
let out = h.call(&id, params.clone().unwrap_or_default());
assert_eq!(out["ok"], true, "{out}");
assert_eq!(h.app.agent_chat.open, open, "Agent Chat toggles as in the menu");
}
for action in [FileAction::Settings, FileAction::Shortcuts] {
let (id, params) = id_of(&action);
let out = h.call(&id, params.unwrap_or_default());
assert_eq!(out["ok"], true, "{action:?}: {out}");
}
// install_cli links into the system; its menu and control paths are the same `help::run`.
for item in HELP.into_iter().filter(|h| *h != "install_cli") {
let out = h.call(HELP_ID, json!({ "item": item }));
assert_eq!(out["ok"], true, "{item}: {out}");
}
assert_eq!(h.call(HELP_ID, json!({"item": "nothing"}))["ok"], false, "an unknown help item is an error");
let out = h.call(&id_of(&FileAction::Quit).0, json!({}));
assert_eq!((out["ok"].clone(), h.app.quit_requested), (json!(true), true), "{out}");
}
}
-1
View File
@@ -18,7 +18,6 @@ assets/
ui/source/ # Rust viewport glyphs, markers and navigation
branding/web/source/og.png # shared social preview card
branding/logo/ # print/web logo files; guide in branding/README.md
styles/ui/palettes.rs # light/dark defaults
styles/web/loading.css # web loading/canvas styles
appearances/library.rs # built-in visual presets
materials/library.rs # physical defaults and colours
+1 -1
View File
@@ -38,7 +38,7 @@ Other colours, all already used in the project:
| Colour | Hex | Where it comes from |
|---|---|---|
| Wordmark blue | `#15499e` | loading logo `--word` |
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
| Interface accent | `#2678da` | light theme `accent` token (`crates/theme/src/palettes.rs`) |
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
| Dark background | `#1f232b` | dark theme `file_background` token |
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
+1 -1
View File
@@ -81,7 +81,7 @@ for message in icon_palette.validation_errors(pal, names | set(al)):
err(d + '/palette.toml', message)
# the review page lies its icons on the real app surfaces: its colours must be the app's tokens
def app_tokens():
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read()
src = open(os.path.join(d, '..', '..', '..', '..', 'crates', 'theme', 'src', 'palettes.rs'), encoding='utf-8').read()
lo, hi = src.index('pub const fn light'), src.index('pub const fn dark')
light_src, dark_src = src[lo:hi], src[hi:]
def pick(block, name, dark):
@@ -1,4 +1,4 @@
/* App surfaces (keep in sync with assets/styles/ui/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
/* App surfaces (keep in sync with crates/theme/src/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
:root{--bg:#fafbfc;--fg:#262a32;--card:#f6f7f9;--line:#d0d4dc;--mut:#6e7480;--sz:48px}
body.dark{--bg:#2e3440;--fg:#dee2e8;--card:#272b34;--line:#3f4552;--mut:#949ba8}
body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)}
+2 -2
View File
@@ -110,7 +110,7 @@ pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
}
pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
brand_mark_styled(p, c, 20.0, crate::theme::Tokens::get().logo, true);
brand_mark_styled(p, c, 20.0, solidcraft_theme::Tokens::get().logo, true);
}
/// The same original mark as a single quiet silhouette, without face seams or outlines.
@@ -127,7 +127,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
};
// The faces are separated by gaps that show the application bar, not by white lines.
let [light, mid, dark] = colours;
let edge_col = crate::theme::Tokens::get().app_bar;
let edge_col = solidcraft_theme::Tokens::get().app_bar;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
+2 -2
View File
@@ -1,7 +1,7 @@
//! Constraint, assembly snap, hole placement and dimension marker artwork.
use crate::dialogs_assembly::SnapKind;
use crate::theme::Tokens;
use egui::{Color32, Pos2, Stroke, vec2};
use solidcraft_theme::Tokens;
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
@@ -49,7 +49,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
// What it snaps to (hovered), or which component it is (picked): a small label.
if let Some(tag) = tag {
let t = Tokens::get();
let font = egui::FontId::proportional(crate::theme::Tokens::get().font_caption);
let font = egui::FontId::proportional(solidcraft_theme::Tokens::get().font_caption);
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
let at = c + vec2(12.0, 10.0);
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
+6 -6
View File
@@ -1,8 +1,8 @@
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
use crate::theme::Tokens;
use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
use solidcore_record::Vec3;
use solidcraft_engine::geom::Ring;
use solidcraft_theme::Tokens;
pub(crate) struct Facet {
pub(crate) direction: Vec3,
pub(crate) points: Ring<Pos2>,
@@ -150,16 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
}
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
pub(crate) const AXIS_RADIUS: f32 = 15.0;
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens) {
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
}
/// The same compass artwork at a uniform scale, including its lettering and strokes.
#[allow(clippy::too_many_arguments)]
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d.x != 0.0 {
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d[0] != 0.0 {
t.axis_x
} else if d.y != 0.0 {
} else if d[1] != 0.0 {
t.axis_y
} else {
t.axis_z
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &
// On a hovered (white) disc the letter takes the axis colour.
let letter = if hovered { col } else { t.on_accent };
let label = name.to_uppercase();
let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::theme::Tokens::get().font_compass * scale), letter);
let galley = painter.layout_no_wrap(label, crate::fonts::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let origin = at - galley.size() / 2.0;
let ppp = painter.ctx().pixels_per_point().max(0.5);
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
+3 -3
View File
@@ -2,16 +2,16 @@
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
pub(crate) fn agent() -> Color32 {
crate::theme::Tokens::get().icons.agent
solidcraft_theme::Tokens::get().icons.agent
}
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
let ic = crate::theme::Tokens::get().icons;
let ic = solidcraft_theme::Tokens::get().icons;
// An arrow pointer, tip at `p`.
let pts: Vec<Pos2> =
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::theme::Tokens::get().font_small), ic.highlight);
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(solidcraft_theme::Tokens::get().font_small), ic.highlight);
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
painter.rect_filled(r, 4.0, ic.agent);
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
+26 -5
View File
@@ -977,7 +977,7 @@
"browser.calibrate_click_a": "انقر على الصورة لتحديد النقطة A، أو اكتب إحداثياتها.",
"browser.calibrate_click_b": "انقر على الصورة لتحديد النقطة B، أو اكتب إحداثياتها.",
"browser.calibrate_title": "معايرة: {name}",
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولاً.",
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولًا.",
"browser.capture_many": "تم تحريك {count} مكوّنات دون تثبيت مواضعها.",
"browser.capture_note": "التثبيت يحتفظ بالموضع الجديد، والإرجاع يعيده كما كان.",
"browser.capture_one": "تم تحريك مكوّن دون تثبيت موضعه.",
@@ -1207,6 +1207,7 @@
"cmd.material_properties_directional": "معامل المرونة الاتجاهي",
"cmd.material_properties_validate": "التحقق من بيانات المادة الفيزيائية",
"cmd.mesh_convert": "تحويل الشبكة",
"cmd.model_body_features": "سرد ميزات الجسم",
"cmd.model_edges": "قائمة الحواف",
"cmd.model_faces": "قائمة الأوجه",
"cmd.model_threads": "قائمة الأسنان اللولبية",
@@ -1235,8 +1236,11 @@
"cmd.panel.dismiss": "إغلاق العرض المؤقت للوحة",
"cmd.panel.float": "تعويم اللوحة",
"cmd.panel.focus": "تركيز اللوحة",
"cmd.panel.focus.next": "التركيز على اللوحة التالية",
"cmd.panel.focus.previous": "التركيز على اللوحة السابقة",
"cmd.panel.list": "سرد اللوحات",
"cmd.panel.move": "نقل اللوحة",
"cmd.panel.move.next_zone": "نقل اللوحة إلى المنطقة التالية",
"cmd.panel.native_close": "إعادة إرساء قسم اللوحة من نافذته المستقلة",
"cmd.panel.open": "فتح اللوحة",
"cmd.panel.peek": "عرض اللوحة المطوية مؤقتًا",
@@ -1244,6 +1248,12 @@
"cmd.panel.reset_pair": "إعادة ضبط فاصل اللوحة",
"cmd.panel.resize": "تغيير حجم قسم اللوحة",
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
"cmd.parameters_add": "إضافة معامل مستخدم",
"cmd.parameters_change": "تغيير المعاملات",
"cmd.parameters_comment": "التعليق على المعامل",
@@ -1864,7 +1874,7 @@
"help.sketch.constrainer": "يطبق القيد الذي يكاد منحنى أو منحنيان محددان يحققانه، مثل الأفقي أو التوازي أو التماس أو التساوي. استخدمه لتهذيب رسم يدوي علاقة بعد أخرى.",
"help.sketch.constraint.coincident": "يجعل نقطة تقع على نقطة أخرى أو على منحنى أو على محور الرسم. استخدمه لسد الفجوات في المقطع أو لوضع نقطة على خط.",
"help.sketch.constraint.collinear": "يجعل خطين يقعان على خط مستقيم واحد. استخدمه لمحاذاة الحواف المتقطعة، مثل جانبي حز.",
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم التخطيطي، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
"help.sketch.constraint.curvature": "يجعل منحنيين يلتقيان عند نقطة نهاية يتابعان بالانحناء نفسه (G2). استخدمه حيث لا يكفي الوصل المماسي نعومة، مثلًا على الأسطح الظاهرة.",
"help.sketch.constraint.equal": "يجعل الخطوط بالطول نفسه، أو الدوائر والأقواس بنصف القطر نفسه. استخدمه ليتحكم بُعد واحد في عدة ميزات متماثلة.",
"help.sketch.constraint.fix": "يثبّت النقاط أو المنحنيات في مكانها، أو يفك تثبيتها. استخدمه للهندسة التي يجب ألا تتحرك عند سحب الرسم أو حله.",
@@ -2518,6 +2528,8 @@
"prefs.bottom_centre": "أسفل الوسط",
"prefs.bottom_right": "أسفل اليمين",
"prefs.browser": "المستعرض",
"prefs.browser_body_features": "الميزات تحت الأجسام",
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات الخط الزمني التي بنته، بترتيب الخط الزمني.",
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
@@ -2558,6 +2570,8 @@
"prefs.design_tools.degrees": "درجات",
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
"prefs.design_tools.hole_group": "تجميع الثقوب المُنشأة معًا",
"prefs.design_tools.hole_group_help": "الثقوب المُنشأة دفعة واحدة تشترك في مجلد واحد في السجل يبدأ مطويًا؛ افتحه لتحرير كل ثقب على حدة.",
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
@@ -2642,6 +2656,8 @@
"prefs.history": "السجل",
"prefs.history_bar_groups": "طي شريط السجل في مجموعات",
"prefs.history_bar_groups_note": "في سجل طويل، تُطوى سلسلة ميزات المكوّن الواحد في شريحة واحدة، وتبدأ مجلدات الميزات مطوية. تدخل مجموعة STEP المُدرجة في مجلد Group. عند الإيقاف تظهر كل ميزة، وتبدأ المجلدات مفتوحة، ولا يُنشأ أي مجلد.",
"prefs.history_folder_kind_icon": "أيقونة المجلد من ميزاته",
"prefs.history_folder_kind_icon_note": "مجلد السجل الذي تكون ميزاته كلها من نوع واحد يعرض أيقونتها.",
"prefs.history_group_joints": "تجميع المفاصل في السجل",
"prefs.history_group_joints_note": "يظهر مفصلان أو أكثر كزر واحد مع عددها، والنقر عليه يُظهر كل مفصل. عند الإيقاف يظهر كل مفصل على حدة.",
"prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى",
@@ -2835,6 +2851,8 @@
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
"prefs.selecting": "التحديد",
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
@@ -2857,8 +2875,8 @@
"prefs.sketch.drawing": "الرسم",
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
"prefs.sketch.escape_twice_finishes": "الضغط المزدوج على Esc ينهي الرسم التخطيطي",
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم التخطيطي. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم التخطيطي.",
"prefs.sketch.escape_twice_finishes": "الضغط على Esc مرتين ينهي الرسم",
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم.",
"prefs.sketch.exit_keep": "إبقاء",
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
@@ -3379,7 +3397,7 @@
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
"render.cpu_path_depth": "عمق المسار",
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_lighting": "إضاءة غير مباشرة",
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_point": "ضوء نقطي",
@@ -5525,6 +5543,7 @@
"ui.heading_plastic": "البلاستيك",
"ui.heading_position": "الموضع",
"ui.heading_prepare": "تحضير",
"ui.heading_press_pull": "ضغط وسحب",
"ui.heading_rectangular_pattern": "نمط مستطيلي",
"ui.heading_revolve": "دوران",
"ui.heading_right": "يمين",
@@ -5868,6 +5887,8 @@
"ui.profiles": "المقاطع",
"ui.project": "إسقاط",
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
"ui.properties": "الخصائص",
"ui.properties.copy_matrix": "نسخ المصفوفة",
"ui.properties.first_moments": "العزوم الأولى",
+751 -3
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -1018,6 +1018,7 @@
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
"cmd.mesh_convert": "Netz konvertieren",
"cmd.model_body_features": "Körper-Features auflisten",
"cmd.model_edges": "Kanten auflisten",
"cmd.model_faces": "Flächen auflisten",
"cmd.model_threads": "Gewinde auflisten",
@@ -1046,8 +1047,11 @@
"cmd.panel.dismiss": "Panel-Vorschau schließen",
"cmd.panel.float": "Panel frei platzieren",
"cmd.panel.focus": "Panel fokussieren",
"cmd.panel.focus.next": "Nächstes Panel fokussieren",
"cmd.panel.focus.previous": "Vorheriges Panel fokussieren",
"cmd.panel.list": "Panels auflisten",
"cmd.panel.move": "Panel verschieben",
"cmd.panel.move.next_zone": "Panel in nächste Zone verschieben",
"cmd.panel.native_close": "Nativen Panel-Bereich wieder andocken",
"cmd.panel.open": "Panel öffnen",
"cmd.panel.peek": "Eingeklapptes Panel anzeigen",
@@ -1055,6 +1059,12 @@
"cmd.panel.reset_pair": "Panel-Trennlinie zurücksetzen",
"cmd.panel.resize": "Panel-Bereich skalieren",
"cmd.panel.resize_pair": "Panel-Trennlinie verschieben",
"cmd.panel.sash.down": "Panel-Trennlinie nach unten verschieben",
"cmd.panel.sash.left": "Panel-Trennlinie nach links verschieben",
"cmd.panel.sash.reset": "Trennlinie des fokussierten Panels zurücksetzen",
"cmd.panel.sash.right": "Panel-Trennlinie nach rechts verschieben",
"cmd.panel.sash.up": "Panel-Trennlinie nach oben verschieben",
"cmd.panel.toggle": "Fokussiertes Panel ein- oder ausklappen",
"cmd.parameters_add": "Benutzerparameter hinzufügen",
"cmd.parameters_change": "Parameter ändern",
"cmd.parameters_comment": "Parameter kommentieren",
@@ -2305,6 +2315,8 @@
"prefs.bottom_centre": "Unten mittig",
"prefs.bottom_right": "Unten rechts",
"prefs.browser": "Browser",
"prefs.browser_body_features": "Features unter Körpern",
"prefs.browser_body_features_note": "Jeder Körper im Browser lässt sich aufklappen und zeigt die Features der Zeitleiste, die ihn erzeugt haben, in deren Reihenfolge.",
"prefs.browser_components_open": "Komponenten zu Beginn aufgeklappt",
"prefs.browser_components_open_note": "Jede Komponente im Browser ist wie bisher von Anfang an aufgeklappt, statt zugeklappt zu bleiben, bis du sie aufklappst.",
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
@@ -2345,6 +2357,8 @@
"prefs.design_tools.degrees": "Grad",
"prefs.design_tools.double_click_help": "Oder zum Bearbeiten einer verknüpften Komponente an Ort und Stelle.",
"prefs.design_tools.ghost": "Alte Position verschobener Teile als Geisterbild zeigen",
"prefs.design_tools.hole_group": "Gemeinsam erstellte Bohrungen gruppieren",
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten.",
"prefs.design_tools.hole_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
"prefs.design_tools.hole_several": "Mehrere Bohrungen auf einer Fläche",
"prefs.design_tools.hole_several_help": "Eine Auswahl an anderer Stelle der Fläche fügt eine weitere Bohrung hinzu; die Auswahl einer Bohrung entfernt sie.",
@@ -2429,6 +2443,8 @@
"prefs.history": "Verlauf",
"prefs.history_bar_groups": "Verlaufsleiste in Gruppen falten",
"prefs.history_bar_groups_note": "In einem langen Verlauf wird eine Folge von Features einer Komponente zu einem Chip gefaltet, und Merkmalsordner beginnen gefaltet. Eine eingefügte STEP-Baugruppe kommt in einen Group-Ordner. Aus zeigt jedes Feature, öffnet Ordner und legt keinen Ordner an.",
"prefs.history_folder_kind_icon": "Ordnersymbol aus seinen Features",
"prefs.history_folder_kind_icon_note": "Ein Ordner im Verlauf, dessen Features alle von einer Art sind, zeigt deren Symbol.",
"prefs.history_group_joints": "Gelenke im Verlauf gruppieren",
"prefs.history_group_joints_note": "Zwei oder mehr Gelenke erscheinen als eine Schaltfläche mit ihrer Anzahl; ein Klick zeigt jedes einzeln. Aus zeigt jedes Gelenk einzeln.",
"prefs.history_origin_bodies": "Ursprung und Körper oben zeigen",
@@ -2595,6 +2611,8 @@
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
"prefs.selecting": "Auswahl",
"prefs.shared_panel_layout": "Gemeinsames Panellayout verwenden",
"prefs.shared_panel_layout_help": "Browser, Verlauf und Assistent in verschiebbaren Panels anzeigen. Die Änderung wird beim Anwenden der Einstellungen wirksam.",
"prefs.shift": "Umschalt",
"prefs.shift_add_ctrl_toggle": "Umschalt fügt nur hinzu; Strg / Cmd schaltet die Auswahl um",
"prefs.shift_add_note": "Aktiviert: Umschalt fügt Elemente zur Auswahl hinzu, Strg / Cmd schaltet ihre Auswahl um. Deaktiviert (Standard): Beide Tasten schalten die Auswahl um.",
@@ -5245,6 +5263,7 @@
"ui.heading_plastic": "PLASTIK",
"ui.heading_position": "POSITION",
"ui.heading_prepare": "VORBEREITEN",
"ui.heading_press_pull": "DRÜCKEN/ZIEHEN",
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
"ui.heading_revolve": "DREHEN",
"ui.heading_right": "RECHTS",
@@ -5584,6 +5603,8 @@
"ui.profiles": "Profile",
"ui.project": "Projizieren",
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
"ui.project_references_construction": "Referenzen als Konstruktionsgeometrie projizieren",
"ui.project_references_construction_note": "Projizieren, Schneiden, 3D-Geometrie einbeziehen und die Flächenkanten einer neuen Skizze auf einer Fläche erzeugen Konstruktionsgeometrie: Bezüge zum Zeichnen, die nicht zu Profilen gehören. Sie bleiben mit dem Modell verknüpft.",
"ui.properties": "Eigenschaften",
"ui.properties.copy_matrix": "Matrix kopieren",
"ui.properties.first_moments": "Statische Momente",
+21
View File
@@ -1207,6 +1207,7 @@
"cmd.material_properties_directional": "Directional Elastic Modulus",
"cmd.material_properties_validate": "Validate Physical Material Data",
"cmd.mesh_convert": "Convert Mesh",
"cmd.model_body_features": "List Body Features",
"cmd.model_edges": "List Edges",
"cmd.model_faces": "List Faces",
"cmd.model_threads": "List Threads",
@@ -1235,8 +1236,11 @@
"cmd.panel.dismiss": "Dismiss Panel Peek",
"cmd.panel.float": "Float Panel",
"cmd.panel.focus": "Focus Panel",
"cmd.panel.focus.next": "Focus Next Panel",
"cmd.panel.focus.previous": "Focus Previous Panel",
"cmd.panel.list": "List Panels",
"cmd.panel.move": "Move Panel",
"cmd.panel.move.next_zone": "Move Panel to Next Zone",
"cmd.panel.native_close": "Redock Native Panel Section",
"cmd.panel.open": "Open Panel",
"cmd.panel.peek": "Peek Collapsed Panel",
@@ -1244,6 +1248,12 @@
"cmd.panel.reset_pair": "Reset Panel Sash",
"cmd.panel.resize": "Resize Panel Section",
"cmd.panel.resize_pair": "Resize Panel Sash",
"cmd.panel.sash.down": "Resize Panel Sash Down",
"cmd.panel.sash.left": "Resize Panel Sash Left",
"cmd.panel.sash.reset": "Reset Focused Panel Sash",
"cmd.panel.sash.right": "Resize Panel Sash Right",
"cmd.panel.sash.up": "Resize Panel Sash Up",
"cmd.panel.toggle": "Toggle Focused Panel",
"cmd.parameters_add": "Add User Parameter",
"cmd.parameters_change": "Change Parameters",
"cmd.parameters_comment": "Comment Parameter",
@@ -2518,6 +2528,8 @@
"prefs.bottom_centre": "Bottom center",
"prefs.bottom_right": "Bottom right",
"prefs.browser": "Browser",
"prefs.browser_body_features": "Features under bodies",
"prefs.browser_body_features_note": "Each body in the browser folds open on the timeline features that built it, in timeline order.",
"prefs.browser_components_open": "Components start open",
"prefs.browser_components_open_note": "Every component in the browser starts open, as before, instead of closed until you open it.",
"prefs.browser_eye_left": "Eye beside the fold arrow",
@@ -2558,6 +2570,8 @@
"prefs.design_tools.degrees": "Degrees",
"prefs.design_tools.double_click_help": "Or to edit a linked one in place.",
"prefs.design_tools.ghost": "Show moved parts' old place as a ghost",
"prefs.design_tools.hole_group": "Group holes made together",
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own.",
"prefs.design_tools.hole_moves_help": "Picking the face again moves the hole instead of removing it.",
"prefs.design_tools.hole_several": "Several holes on one face",
"prefs.design_tools.hole_several_help": "Picking elsewhere on a face adds another hole; picking a hole removes it.",
@@ -2642,6 +2656,8 @@
"prefs.history": "History",
"prefs.history_bar_groups": "Fold the History bar into groups",
"prefs.history_bar_groups_note": "In a long history, a run of one component's features folds into one chip, and feature folders start folded. An inserted STEP assembly goes into a Group folder. Off shows each feature, starts folders open and makes no folder.",
"prefs.history_folder_kind_icon": "Folder icon from its features",
"prefs.history_folder_kind_icon_note": "A History folder whose features are all of one kind shows their icon.",
"prefs.history_group_joints": "Group joints in the History",
"prefs.history_group_joints_note": "Two or more joints show as one button with their count, and a click shows each one. Off shows each joint.",
"prefs.history_origin_bodies": "Show origin and bodies at the top",
@@ -2835,6 +2851,8 @@
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
"prefs.selecting": "Selecting",
"prefs.shared_panel_layout": "Use shared panel layout",
"prefs.shared_panel_layout_help": "Show the existing Browser, History and Assistant in movable panels. The change takes effect when preferences are applied.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
@@ -5525,6 +5543,7 @@
"ui.heading_plastic": "PLASTIC",
"ui.heading_position": "POSITION",
"ui.heading_prepare": "PREPARE",
"ui.heading_press_pull": "PRESS PULL",
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
"ui.heading_revolve": "REVOLVE",
"ui.heading_right": "RIGHT",
@@ -5868,6 +5887,8 @@
"ui.profiles": "Profiles",
"ui.project": "Project",
"ui.project_face_edges_sketch": "Project face edges into new sketches",
"ui.project_references_construction": "Project references as construction geometry",
"ui.project_references_construction_note": "Project, Intersect, Include 3D Geometry and the face edges of a new sketch on a face make construction geometry: references to draw against, left out of profiles. They stay linked to the model.",
"ui.properties": "Properties",
"ui.properties.copy_matrix": "Copy matrix",
"ui.properties.first_moments": "First moments",
+3
View File
@@ -1000,6 +1000,7 @@
"cmd.material_properties_directional": "Module d'élasticité directionnel",
"cmd.material_properties_validate": "Valider les données du matériau physique",
"cmd.mesh_convert": "Convertir le maillage",
"cmd.model_body_features": "Lister les fonctions du corps",
"cmd.model_edges": "Lister les arêtes",
"cmd.model_faces": "Lister les faces",
"cmd.model_threads": "Lister les filetages",
@@ -2164,6 +2165,8 @@
"prefs.bottom_centre": "Bas centre",
"prefs.bottom_right": "Bas droite",
"prefs.browser": "Navigateur",
"prefs.browser_body_features": "Fonctions sous les corps",
"prefs.browser_body_features_note": "Chaque corps du navigateur se déplie sur les fonctions de l'historique qui l'ont construit, dans l'ordre de l'historique.",
"prefs.browser_components_open": "Composants dépliés au départ",
"prefs.browser_components_open_note": "Chaque composant du navigateur commence déplié, comme avant, au lieu de rester replié jusqu'à ce que vous le dépliiez.",
"prefs.browser_eye_left": "Œil à côté de la flèche de dépliage",
+21
View File
@@ -1207,6 +1207,7 @@
"cmd.material_properties_directional": "מודול אלסטיות כיווני",
"cmd.material_properties_validate": "אימות נתוני חומר פיזיקלי",
"cmd.mesh_convert": "המרת רשת משולשים",
"cmd.model_body_features": "הצגת פיצ'רים של גוף",
"cmd.model_edges": "רשימת מקצועות",
"cmd.model_faces": "רשימת פאות",
"cmd.model_threads": "רשימת הברגות",
@@ -1235,8 +1236,11 @@
"cmd.panel.dismiss": "סגירת תצוגה זמנית של חלונית",
"cmd.panel.float": "הצפת חלונית",
"cmd.panel.focus": "מיקוד חלונית",
"cmd.panel.focus.next": "מיקוד בלוח הבא",
"cmd.panel.focus.previous": "מיקוד בלוח הקודם",
"cmd.panel.list": "הצגת רשימת חלוניות",
"cmd.panel.move": "העברת חלונית",
"cmd.panel.move.next_zone": "העברת הלוח לאזור הבא",
"cmd.panel.native_close": "עיגון מחדש של מקטע חלונית מחלון נפרד",
"cmd.panel.open": "פתיחת חלונית",
"cmd.panel.peek": "הצגה זמנית של חלונית מכווצת",
@@ -1244,6 +1248,12 @@
"cmd.panel.reset_pair": "איפוס מפריד חלוניות",
"cmd.panel.resize": "שינוי גודל מקטע חלונית",
"cmd.panel.resize_pair": "הזזת מפריד חלוניות",
"cmd.panel.sash.down": "הזזת מפריד הלוח למטה",
"cmd.panel.sash.left": "הזזת מפריד הלוח שמאלה",
"cmd.panel.sash.reset": "איפוס מפריד הלוח שבמיקוד",
"cmd.panel.sash.right": "הזזת מפריד הלוח ימינה",
"cmd.panel.sash.up": "הזזת מפריד הלוח למעלה",
"cmd.panel.toggle": "שינוי מצב הכיווץ של הלוח שבמיקוד",
"cmd.parameters_add": "הוספת פרמטר משתמש",
"cmd.parameters_change": "שינוי פרמטרים",
"cmd.parameters_comment": "הערה לפרמטר",
@@ -2518,6 +2528,8 @@
"prefs.bottom_centre": "למטה במרכז",
"prefs.bottom_right": "למטה מימין",
"prefs.browser": "עץ התכן",
"prefs.browser_body_features": "פיצ'רים מתחת לגופים",
"prefs.browser_body_features_note": "כל גוף בדפדפן נפתח ומציג את הפיצ'רים מציר הזמן שבנו אותו, לפי סדר ציר הזמן.",
"prefs.browser_components_open": "רכיבים פתוחים מההתחלה",
"prefs.browser_components_open_note": "כל רכיב בעץ התכן מתחיל פתוח, כמו קודם, במקום סגור עד שתפתח אותו.",
"prefs.browser_eye_left": "סמל העין ליד חץ הקיפול",
@@ -2558,6 +2570,8 @@
"prefs.design_tools.degrees": "מעלות",
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד.",
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
@@ -2642,6 +2656,8 @@
"prefs.history": "ציר הזמן",
"prefs.history_bar_groups": "קיפול סרגל ציר הזמן לקבוצות",
"prefs.history_bar_groups_note": "בציר זמן ארוך, רצף פיצ'רים של רכיב אחד מתקפל לשבב אחד, ותיקיות פיצ'רים מתחילות מקופלות. מכלול STEP שהוכנס נכנס לתיקיית Group. כשהאפשרות כבויה, כל פיצ'ר מוצג, התיקיות מתחילות פתוחות ולא נוצרת תיקייה.",
"prefs.history_folder_kind_icon": "סמל התיקייה לפי התכונות שבה",
"prefs.history_folder_kind_icon_note": "תיקייה בהיסטוריה שכל התכונות בה מאותו סוג מציגה את הסמל שלהן.",
"prefs.history_group_joints": "קיבוץ מפרקים בציר הזמן",
"prefs.history_group_joints_note": "שני מפרקים או יותר מוצגים ככפתור אחד עם מספרם, ולחיצה מציגה כל אחד מהם. כשהאפשרות כבויה, כל מפרק מוצג בנפרד.",
"prefs.history_origin_bodies": "הצגת הראשית והגופים למעלה",
@@ -2835,6 +2851,8 @@
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
"prefs.selecting": "בחירה",
"prefs.shared_panel_layout": "שימוש בפריסת הלוחות המשותפת",
"prefs.shared_panel_layout_help": "הצגת הדפדפן, ההיסטוריה והעוזר בלוחות ניתנים להזזה. השינוי ייכנס לתוקף לאחר החלת ההעדפות.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
@@ -5525,6 +5543,7 @@
"ui.heading_plastic": "פלסטיק",
"ui.heading_position": "מיקום",
"ui.heading_prepare": "הכנה",
"ui.heading_press_pull": "דחיפה/משיכה",
"ui.heading_rectangular_pattern": "מערך מלבני",
"ui.heading_revolve": "סיבוב",
"ui.heading_right": "ימין",
@@ -5868,6 +5887,8 @@
"ui.profiles": "פרופילים",
"ui.project": "הטלה",
"ui.project_face_edges_sketch": "הטלת מקצועות הפאה לסקיצות חדשות",
"ui.project_references_construction": "הטלת הפניות כגאומטריית עזר",
"ui.project_references_construction_note": "הטלה, חיתוך, הכללת גאומטריה תלת־ממדית ומקצועות הפאה של סקיצה חדשה על פאה יוצרים גאומטריית עזר: הפניות לשרטוט שאינן נכללות בפרופילים. הן נשארות מקושרות למודל.",
"ui.properties": "מאפיינים",
"ui.properties.copy_matrix": "העתקת המטריצה",
"ui.properties.first_moments": "מומנטים ראשונים",
+21
View File
@@ -1207,6 +1207,7 @@
"cmd.material_properties_directional": "方向別弾性率",
"cmd.material_properties_validate": "物理材料データを検証",
"cmd.mesh_convert": "メッシュを変換",
"cmd.model_body_features": "ボディのフィーチャーを一覧表示",
"cmd.model_edges": "エッジを一覧表示",
"cmd.model_faces": "面を一覧表示",
"cmd.model_threads": "ねじを一覧表示",
@@ -1235,8 +1236,11 @@
"cmd.panel.dismiss": "パネルの一時表示を閉じる",
"cmd.panel.float": "パネルをフロート表示",
"cmd.panel.focus": "パネルにフォーカス",
"cmd.panel.focus.next": "次のパネルにフォーカス",
"cmd.panel.focus.previous": "前のパネルにフォーカス",
"cmd.panel.list": "パネルを一覧表示",
"cmd.panel.move": "パネルを移動",
"cmd.panel.move.next_zone": "パネルを次の領域に移動",
"cmd.panel.native_close": "別ウィンドウのパネルを再ドッキング",
"cmd.panel.open": "パネルを開く",
"cmd.panel.peek": "折りたたんだパネルを一時表示",
@@ -1244,6 +1248,12 @@
"cmd.panel.reset_pair": "パネルの区切りをリセット",
"cmd.panel.resize": "パネルのセクションのサイズを変更",
"cmd.panel.resize_pair": "パネルの区切りを移動",
"cmd.panel.sash.down": "パネルの区切りを下に移動",
"cmd.panel.sash.left": "パネルの区切りを左に移動",
"cmd.panel.sash.reset": "フォーカス中のパネルの区切りをリセット",
"cmd.panel.sash.right": "パネルの区切りを右に移動",
"cmd.panel.sash.up": "パネルの区切りを上に移動",
"cmd.panel.toggle": "フォーカス中のパネルの折りたたみを切り替え",
"cmd.parameters_add": "ユーザー パラメータを追加",
"cmd.parameters_change": "パラメータを変更",
"cmd.parameters_comment": "パラメータにコメントを付与",
@@ -2518,6 +2528,8 @@
"prefs.bottom_centre": "下中央",
"prefs.bottom_right": "右下",
"prefs.browser": "ブラウザー",
"prefs.browser_body_features": "ボディの下にフィーチャー",
"prefs.browser_body_features_note": "ブラウザーの各ボディを展開すると、そのボディを作ったタイムラインのフィーチャーがタイムライン順に表示されます。",
"prefs.browser_components_open": "コンポーネントを最初から展開",
"prefs.browser_components_open_note": "ブラウザーのすべてのコンポーネントを以前と同じく最初から展開して表示します。オフの場合は展開するまで折りたたまれています。",
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
@@ -2558,6 +2570,8 @@
"prefs.design_tools.degrees": "度",
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。",
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
"prefs.design_tools.hole_several": "1つの面に複数の穴",
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
@@ -2642,6 +2656,8 @@
"prefs.history": "履歴",
"prefs.history_bar_groups": "履歴バーをグループにまとめる",
"prefs.history_bar_groups_note": "長い履歴では、1つのコンポーネントの連続したフィーチャーを1つのチップにまとめ、フィーチャーフォルダは閉じた状態で始まります。挿入したSTEPアセンブリはGroupフォルダに入ります。オフにすると、各フィーチャーを表示し、フォルダは開いた状態で始まり、フォルダは作成されません。",
"prefs.history_folder_kind_icon": "中のフィーチャーのアイコンをフォルダーに使う",
"prefs.history_folder_kind_icon_note": "中のフィーチャーがすべて同じ種類の履歴フォルダーは、そのアイコンで表示されます。",
"prefs.history_group_joints": "履歴でジョイントをまとめる",
"prefs.history_group_joints_note": "2つ以上のジョイントを、数を添えた1つのボタンにまとめます。クリックすると各ジョイントを表示します。オフにすると各ジョイントを個別に表示します。",
"prefs.history_origin_bodies": "原点とボディを先頭に表示",
@@ -2835,6 +2851,8 @@
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
"prefs.selecting": "選択",
"prefs.shared_panel_layout": "共通のパネルレイアウトを使用",
"prefs.shared_panel_layout_help": "既存のブラウザー、履歴、アシスタントを移動可能なパネルに表示します。設定を適用すると変更が有効になります。",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
@@ -5525,6 +5543,7 @@
"ui.heading_plastic": "プラスチック",
"ui.heading_position": "配置",
"ui.heading_prepare": "準備",
"ui.heading_press_pull": "プレス プル",
"ui.heading_rectangular_pattern": "矩形パターン",
"ui.heading_revolve": "回転",
"ui.heading_right": "右側面",
@@ -5868,6 +5887,8 @@
"ui.profiles": "プロファイル",
"ui.project": "投影",
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
"ui.project_references_construction": "参照をコンストラクションジオメトリとして投影",
"ui.project_references_construction_note": "投影、交差、3D ジオメトリを含める、および面上の新規スケッチの面のエッジは、コンストラクションジオメトリになります。描画の参照用で、プロファイルには含まれません。モデルとのリンクは保たれます。",
"ui.properties": "プロパティ",
"ui.properties.copy_matrix": "行列をコピー",
"ui.properties.first_moments": "一次モーメント",
+18
View File
@@ -992,8 +992,11 @@
"cmd.panel.dismiss": "패널 임시 보기 닫기",
"cmd.panel.float": "패널 띄우기",
"cmd.panel.focus": "패널에 초점 맞추기",
"cmd.panel.focus.next": "다음 패널에 초점 맞추기",
"cmd.panel.focus.previous": "이전 패널에 초점 맞추기",
"cmd.panel.list": "패널 목록",
"cmd.panel.move": "패널 이동",
"cmd.panel.move.next_zone": "패널을 다음 영역으로 이동",
"cmd.panel.native_close": "별도 창의 패널 구역 다시 도킹",
"cmd.panel.open": "패널 열기",
"cmd.panel.peek": "접힌 패널 임시 보기",
@@ -1001,6 +1004,12 @@
"cmd.panel.reset_pair": "패널 구분선 초기화",
"cmd.panel.resize": "패널 구역 크기 조정",
"cmd.panel.resize_pair": "패널 구분선 이동",
"cmd.panel.sash.down": "패널 구분선을 아래로 이동",
"cmd.panel.sash.left": "패널 구분선을 왼쪽으로 이동",
"cmd.panel.sash.reset": "초점이 있는 패널 구분선 초기화",
"cmd.panel.sash.right": "패널 구분선을 오른쪽으로 이동",
"cmd.panel.sash.up": "패널 구분선을 위로 이동",
"cmd.panel.toggle": "초점이 있는 패널 접기 전환",
"cmd.parameters_add": "사용자 매개 변수 추가",
"cmd.parameters_change": "매개 변수 변경",
"cmd.parameters_comment": "매개 변수에 설명 추가",
@@ -1929,6 +1938,8 @@
"prefs.design_tools.degrees": "도",
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요.",
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
"prefs.design_tools.imported_files": "가져온 파일",
@@ -2006,6 +2017,8 @@
"prefs.history": "이력",
"prefs.history_bar_groups": "이력 막대를 그룹으로 접기",
"prefs.history_bar_groups_note": "긴 이력에서는 한 컴포넌트의 연속된 특성을 하나의 칩으로 접고, 특성 폴더는 접힌 상태로 시작합니다. 삽입한 STEP 어셈블리는 Group 폴더에 들어갑니다. 끄면 각 특성을 표시하고, 폴더를 펼친 상태로 시작하며, 폴더를 만들지 않습니다.",
"prefs.history_folder_kind_icon": "폴더 아이콘을 안의 피처에서 가져오기",
"prefs.history_folder_kind_icon_note": "피처가 모두 같은 종류인 기록 폴더는 그 피처의 아이콘을 표시합니다.",
"prefs.history_group_joints": "이력에서 조인트 묶기",
"prefs.history_group_joints_note": "조인트가 두 개 이상이면 개수가 표시된 버튼 하나로 묶고, 클릭하면 각 조인트를 표시합니다. 끄면 각 조인트를 하나씩 표시합니다.",
"prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시",
@@ -2133,6 +2146,8 @@
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
"prefs.selecting": "선택",
"prefs.shared_panel_layout": "공유 패널 레이아웃 사용",
"prefs.shared_panel_layout_help": "기존 브라우저, 기록 및 도우미를 이동 가능한 패널로 표시합니다. 환경설정을 적용하면 변경 사항이 반영됩니다.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
@@ -4717,6 +4732,7 @@
"ui.heading_plastic": "플라스틱",
"ui.heading_position": "배치",
"ui.heading_prepare": "준비",
"ui.heading_press_pull": "밀기/당기기",
"ui.heading_rectangular_pattern": "직사각형 패턴",
"ui.heading_revolve": "회전",
"ui.heading_right": "우측면",
@@ -5038,6 +5054,8 @@
"ui.profiles": "프로파일",
"ui.project": "투영",
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
"ui.project_references_construction": "참조를 구성선으로 투영",
"ui.project_references_construction_note": "투영, 교차, 3D 형상 포함과 면 위 새 스케치의 면 모서리가 구성선으로 만들어집니다. 프로파일에 포함되지 않는 그리기용 참조이며, 모델과의 연결은 유지됩니다.",
"ui.properties": "속성",
"ui.properties.copy_matrix": "행렬 복사",
"ui.properties.first_moments": "1차 모멘트",
@@ -0,0 +1,101 @@
{
"ar": {
"material.editor.remove": "إزالة"
},
"bg": {
"material.editor.remove": "Remove"
},
"bs": {
"material.editor.remove": "Ukloni"
},
"ca": {
"material.editor.remove": "Eliminar"
},
"cs": {
"material.editor.remove": "Odebrat"
},
"da": {
"material.editor.remove": "Fjern"
},
"de": {
"material.editor.remove": "Entfernen"
},
"en": {
"material.editor.remove": "Remove"
},
"es": {
"material.editor.remove": "Eliminar"
},
"fi": {
"material.editor.remove": "Poista"
},
"fr": {
"material.editor.remove": "Retirer"
},
"gl": {
"material.editor.remove": "Quitar"
},
"he": {
"material.editor.remove": "הסרה"
},
"hr": {
"material.editor.remove": "Ukloni"
},
"hu": {
"material.editor.remove": "Eltávolítás"
},
"id": {
"material.editor.remove": "Hapus"
},
"it": {
"material.editor.remove": "Rimuovi"
},
"ja": {
"material.editor.remove": "削除"
},
"ka": {
"material.editor.remove": "წაშლა"
},
"ms": {
"material.editor.remove": "Buang"
},
"nb": {
"material.editor.remove": "Fjern"
},
"nl": {
"material.editor.remove": "Verwijderen"
},
"pl": {
"material.editor.remove": "Usuń"
},
"pt-BR": {
"material.editor.remove": "Remover"
},
"ro": {
"material.editor.remove": "Elimină"
},
"ru": {
"material.editor.remove": "Убрать"
},
"sr": {
"material.editor.remove": "Уклони"
},
"sv": {
"material.editor.remove": "Ta bort"
},
"tr": {
"material.editor.remove": "Kaldır"
},
"uk": {
"material.editor.remove": "Вилучити"
},
"vi": {
"material.editor.remove": "Xóa"
},
"zh-TW": {
"material.editor.remove": "刪除"
},
"zh": {
"material.editor.remove": "删除"
}
}
+21
View File
@@ -1207,6 +1207,7 @@
"cmd.material_properties_directional": "Mô đun đàn hồi theo hướng",
"cmd.material_properties_validate": "Xác thực dữ liệu thuộc tính vật liệu",
"cmd.mesh_convert": "Chuyển đổi lưới",
"cmd.model_body_features": "Liệt kê tính năng của thân",
"cmd.model_edges": "Danh sách các cạnh",
"cmd.model_faces": "Danh sách các bề mặt",
"cmd.model_threads": "Danh sách các đoạn ren",
@@ -1235,8 +1236,11 @@
"cmd.panel.dismiss": "Đóng chế độ xem tạm của bảng",
"cmd.panel.float": "Thả nổi bảng",
"cmd.panel.focus": "Đặt tiêu điểm vào bảng",
"cmd.panel.focus.next": "Đặt tiêu điểm vào bảng tiếp theo",
"cmd.panel.focus.previous": "Đặt tiêu điểm vào bảng trước",
"cmd.panel.list": "Liệt kê các bảng",
"cmd.panel.move": "Di chuyển bảng",
"cmd.panel.move.next_zone": "Di chuyển bảng đến vùng tiếp theo",
"cmd.panel.native_close": "Gắn lại vùng bảng từ cửa sổ riêng",
"cmd.panel.open": "Mở bảng",
"cmd.panel.peek": "Xem tạm bảng đã thu gọn",
@@ -1244,6 +1248,12 @@
"cmd.panel.reset_pair": "Đặt lại thanh chia bảng",
"cmd.panel.resize": "Đổi kích thước vùng bảng",
"cmd.panel.resize_pair": "Di chuyển thanh chia bảng",
"cmd.panel.sash.down": "Di chuyển thanh chia bảng xuống",
"cmd.panel.sash.left": "Di chuyển thanh chia bảng sang trái",
"cmd.panel.sash.reset": "Đặt lại thanh chia bảng đang có tiêu điểm",
"cmd.panel.sash.right": "Di chuyển thanh chia bảng sang phải",
"cmd.panel.sash.up": "Di chuyển thanh chia bảng lên",
"cmd.panel.toggle": "Bật/tắt thu gọn bảng đang có tiêu điểm",
"cmd.parameters_add": "Thêm tham số người dùng",
"cmd.parameters_change": "Thay đổi tham số",
"cmd.parameters_comment": "Thêm chú thích tham số",
@@ -2518,6 +2528,8 @@
"prefs.bottom_centre": "Giữa phía dưới",
"prefs.bottom_right": "Góc dưới bên phải",
"prefs.browser": "Cây thư mục",
"prefs.browser_body_features": "Tính năng dưới thân",
"prefs.browser_body_features_note": "Mỗi thân trong trình duyệt có thể mở ra để hiện các tính năng trên dòng thời gian đã tạo nên nó, theo thứ tự dòng thời gian.",
"prefs.browser_components_open": "Thành phần mở sẵn",
"prefs.browser_components_open_note": "Mọi thành phần trong trình duyệt đều mở sẵn như trước, thay vì đóng cho đến khi bạn mở.",
"prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn",
@@ -2558,6 +2570,8 @@
"prefs.design_tools.degrees": "Độ",
"prefs.design_tools.double_click_help": "Hoặc để chỉnh sửa thành phần được liên kết tại chỗ.",
"prefs.design_tools.ghost": "Hiện vị trí cũ của chi tiết đã di chuyển dưới dạng bóng mờ",
"prefs.design_tools.hole_group": "Nhóm các lỗ được tạo cùng lúc",
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ.",
"prefs.design_tools.hole_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
"prefs.design_tools.hole_several": "Nhiều lỗ trên một mặt",
"prefs.design_tools.hole_several_help": "Chọn chỗ khác trên mặt sẽ thêm một lỗ; chọn một lỗ sẽ xóa lỗ đó.",
@@ -2642,6 +2656,8 @@
"prefs.history": "Lịch sử",
"prefs.history_bar_groups": "Thu gọn thanh Lịch sử thành nhóm",
"prefs.history_bar_groups_note": "Trong lịch sử dài, một chuỗi tính năng của cùng một thành phần được thu gọn thành một thẻ, và thư mục tính năng bắt đầu ở dạng thu gọn. Cụm lắp ráp STEP được chèn vào sẽ nằm trong thư mục Group. Khi tắt, mỗi tính năng đều hiện, thư mục bắt đầu ở dạng mở và không tạo thư mục nào.",
"prefs.history_folder_kind_icon": "Biểu tượng thư mục theo các tính năng bên trong",
"prefs.history_folder_kind_icon_note": "Thư mục Lịch sử có các tính năng cùng một loại sẽ hiện biểu tượng của chúng.",
"prefs.history_group_joints": "Nhóm các khớp nối trong Lịch sử",
"prefs.history_group_joints_note": "Từ hai khớp nối trở lên hiện thành một nút kèm số lượng, nhấp vào để hiện từng khớp nối. Khi tắt, mỗi khớp nối hiện riêng.",
"prefs.history_origin_bodies": "Hiển thị gốc tọa độ và thân khối ở trên cùng",
@@ -2835,6 +2851,8 @@
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
"prefs.selecting": "Đang chọn",
"prefs.shared_panel_layout": "Dùng bố cục bảng dùng chung",
"prefs.shared_panel_layout_help": "Hiển thị Trình duyệt, Lịch sử và Trợ lý hiện có trong các bảng có thể di chuyển. Thay đổi có hiệu lực khi áp dụng tùy chọn.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn",
"prefs.shift_add_note": "Bật: Shift để thêm vào vùng chọn, Ctrl / Cmd để đảo chọn. Tắt (mặc định): cả hai đều đảo trạng thái chọn.",
@@ -5525,6 +5543,7 @@
"ui.heading_plastic": "CHI TIẾT NHỰA",
"ui.heading_position": "VỊ TRÍ",
"ui.heading_prepare": "CHUẨN BỊ",
"ui.heading_press_pull": "KÉO/ĐẨY",
"ui.heading_rectangular_pattern": "SAO CHÉP HÌNH CHỮ NHẬT",
"ui.heading_revolve": "XOAY TRÒN TẠO KHỐI",
"ui.heading_right": "HÌNH CHIẾU CẠNH",
@@ -5868,6 +5887,8 @@
"ui.profiles": "Các biên dạng",
"ui.project": "Chiếu hình học",
"ui.project_face_edges_sketch": "Chiếu các cạnh bề mặt vào phác thảo mới",
"ui.project_references_construction": "Chiếu tham chiếu thành đường dựng hình",
"ui.project_references_construction_note": "Chiếu, Giao, Đưa vào hình học 3D và các cạnh bề mặt của phác thảo mới trên một bề mặt tạo ra đường dựng hình: tham chiếu để vẽ theo, không thuộc biên dạng. Chúng vẫn liên kết với mô hình.",
"ui.properties": "Thuộc tính cơ lý",
"ui.properties.copy_matrix": "Sao chép ma trận",
"ui.properties.first_moments": "Mômen bậc nhất",
+21
View File
@@ -1207,6 +1207,7 @@
"cmd.material_properties_directional": "方向弹性模量",
"cmd.material_properties_validate": "验证物理材料数据",
"cmd.mesh_convert": "转换网格",
"cmd.model_body_features": "列出实体特征",
"cmd.model_edges": "列出边",
"cmd.model_faces": "列出面",
"cmd.model_threads": "列出螺纹",
@@ -1235,8 +1236,11 @@
"cmd.panel.dismiss": "关闭面板临时预览",
"cmd.panel.float": "浮动面板",
"cmd.panel.focus": "聚焦面板",
"cmd.panel.focus.next": "聚焦下一个面板",
"cmd.panel.focus.previous": "聚焦上一个面板",
"cmd.panel.list": "列出面板",
"cmd.panel.move": "移动面板",
"cmd.panel.move.next_zone": "将面板移至下一个区域",
"cmd.panel.native_close": "重新停靠独立窗口中的面板分区",
"cmd.panel.open": "打开面板",
"cmd.panel.peek": "临时预览折叠面板",
@@ -1244,6 +1248,12 @@
"cmd.panel.reset_pair": "重置面板分隔条",
"cmd.panel.resize": "调整面板分区大小",
"cmd.panel.resize_pair": "移动面板分隔条",
"cmd.panel.sash.down": "向下移动面板分隔条",
"cmd.panel.sash.left": "向左移动面板分隔条",
"cmd.panel.sash.reset": "重置当前焦点面板的分隔条",
"cmd.panel.sash.right": "向右移动面板分隔条",
"cmd.panel.sash.up": "向上移动面板分隔条",
"cmd.panel.toggle": "切换当前焦点面板的折叠状态",
"cmd.parameters_add": "添加用户参数",
"cmd.parameters_change": "更改参数",
"cmd.parameters_comment": "注释参数",
@@ -2518,6 +2528,8 @@
"prefs.bottom_centre": "底部居中",
"prefs.bottom_right": "右下角",
"prefs.browser": "浏览器",
"prefs.browser_body_features": "实体下显示特征",
"prefs.browser_body_features_note": "浏览器中的每个实体都可展开,按时间线顺序显示构建它的时间线特征。",
"prefs.browser_components_open": "组件初始展开",
"prefs.browser_components_open_note": "浏览器中的每个组件都像以前一样初始展开,而不是在您展开前保持折叠。",
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
@@ -2558,6 +2570,8 @@
"prefs.design_tools.degrees": "度",
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
"prefs.design_tools.hole_group": "将一起创建的孔分组",
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。",
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
"prefs.design_tools.hole_several": "一个面上多个孔",
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
@@ -2642,6 +2656,8 @@
"prefs.history": "历史",
"prefs.history_bar_groups": "将历史栏折叠为分组",
"prefs.history_bar_groups_note": "在较长的历史中,同一组件的连续特征折叠为一个标签,特征文件夹开始时为折叠状态。插入的 STEP 装配体放入 Group 文件夹。关闭后显示每个特征,文件夹开始时为展开状态,且不创建文件夹。",
"prefs.history_folder_kind_icon": "文件夹使用其中特征的图标",
"prefs.history_folder_kind_icon_note": "历史中特征全部属于同一类型的文件夹显示该类型的图标。",
"prefs.history_group_joints": "在历史中将关节分组",
"prefs.history_group_joints_note": "两个或更多关节显示为一个带数量的按钮,单击即可显示每个关节。关闭后逐个显示每个关节。",
"prefs.history_origin_bodies": "在顶部显示原点和实体",
@@ -2835,6 +2851,8 @@
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
"prefs.selecting": "选择",
"prefs.shared_panel_layout": "使用共享面板布局",
"prefs.shared_panel_layout_help": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
@@ -5525,6 +5543,7 @@
"ui.heading_plastic": "塑料",
"ui.heading_position": "位置",
"ui.heading_prepare": "准备",
"ui.heading_press_pull": "按压拖动",
"ui.heading_rectangular_pattern": "矩形阵列",
"ui.heading_revolve": "旋转",
"ui.heading_right": "右视",
@@ -5868,6 +5887,8 @@
"ui.profiles": "轮廓",
"ui.project": "投影",
"ui.project_face_edges_sketch": "将面的边投影到新草图",
"ui.project_references_construction": "将参考投影为构造几何",
"ui.project_references_construction_note": "投影、相交、包含三维几何以及在面上新建草图时投影的面边,都会生成构造几何:用于绘图参考,不计入轮廓。它们仍与模型保持关联。",
"ui.properties": "属性",
"ui.properties.copy_matrix": "复制矩阵",
"ui.properties.first_moments": "一阶矩",
+9 -3
View File
@@ -716,14 +716,20 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
if (dot(n, v) < 0.0) { n = -n; }
if u.product_lights_meta.x>0.0 {
var radiance=vec3<f32>(0.0);
let albedo=srgb_decode(base.rgb);
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
if u.product_lights[k*4u].w == 4.0 {radiance+=albedo*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
let sample=product_light_sample(k,i.wp);
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
radiance+=albedo*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
}
let mapped=radiance/(vec3<f32>(1.0)+radiance);
// A linear attachment takes linear light as it is; any other is encoded once, as the CPU
// image is (decoding the encoded value again through `out_color` would flatten the dark end).
if (u.back.w > 0.5) {
return vec4<f32>(mapped,base.a);
}
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
return out_color(vec4<f32>(encoded,base.a));
return vec4<f32>(encoded,base.a);
}
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
let diff = max(dot(n, key), 0.0);
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "solidcraft-color"
description = "Colour rules and data any crate may share: the sRGB transfer function and the surface analysis palette"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+20
View File
@@ -0,0 +1,20 @@
# solidcraft-color
Colour rules and data that any crate may share. It has no dependencies and holds no app types,
so the renderer, the document, the importers and the UI theme all use the same rule and the same
colours instead of keeping copies.
The sRGB transfer function (IEC 61966-2-1) lives here once: every encode and decode in Rust goes
through `srgb`, so a change to the rule is one change. Shaders can't call Rust and keep their own
copies.
## API
| Item | What it is |
|---|---|
| `srgb::encode(linear)` | Linear light (0..1) to an sRGB value (0..1) |
| `srgb::decode(encoded)` | An sRGB value (0..1) to linear light |
| `srgb::encode8(linear)` | Linear light to the nearest 8-bit sRGB code (out of range clamps, NaN reads as 0) |
| `srgb::decode8(code)` | An 8-bit sRGB code to linear light |
| `AnalysisColors` | The surface analysis palette (draft, accessibility, curvature, hatch), straight sRGB 0..1 as the shader takes it; the theme fills it and the renderer reads it |
| `AnalysisColors::DEFAULT` | The light theme's colours with no colour vision mode, as headless pictures use |
+32
View File
@@ -0,0 +1,32 @@
//! The colours a surface analysis is shaded in.
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
/// reads with hue lost too (#975).
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
impl AnalysisColors {
/// The light theme's colours (no colour vision mode): what headless pictures use.
pub const DEFAULT: AnalysisColors = AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
hatch: false,
};
}
impl Default for AnalysisColors {
fn default() -> Self {
AnalysisColors::DEFAULT
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Colour rules and data shared by every crate: the sRGB transfer function ([`srgb`]) and the surface analysis palette
//! ([`AnalysisColors`]). No app types and no dependencies, so the renderer, the document and the theme all read the same
//! rule and the same colours.
mod analysis;
pub mod srgb;
pub use analysis::AnalysisColors;
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "solidcraft-control-egui"
description = "The window's own control requests any egui app answers alike: ui.screenshot and ui.resize"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solidcraft-control = { workspace = true }
serde_json = { workspace = true }
# The window's own requests (`ui.screenshot`, `ui.resize`) go through egui.
egui = { workspace = true }
# Screenshots are saved in the format their path names.
image = { workspace = true }
+14
View File
@@ -0,0 +1,14 @@
# solidcraft-control-egui
The window's own control requests (docs/control-protocol.md), which any egui app answers the same
way: `ui.screenshot` and `ui.resize`. The token files, the loopback server and the request types
are `solidcraft-control`, which has no UI toolkit, so a headless engine, CLI or MCP bridge uses the
control channel without compiling egui. No app types and no engine.
## API
| Item | What it does |
|---|---|
| `screenshot::Shots` | `ui.screenshot`: frames asked for, taken when the view settles, saved and answered, or refused when hidden, cancelled or late; the app gives the reply for no frame |
| `screenshot::encode` | A frame in the format its path names |
| `window::resize`, `window::hidden` | `ui.resize` over `egui::Context`; whether the window presents frames |
+8
View File
@@ -0,0 +1,8 @@
//! The window's own control requests (docs/control-protocol.md) any app answers alike over egui:
//! `ui.screenshot` ([`screenshot::Shots`]) and `ui.resize` ([`window::resize`]). The token, the
//! server and the requests themselves are `solidcraft-control`, which needs no UI toolkit.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod screenshot;
pub mod window;
+288
View File
@@ -0,0 +1,288 @@
//! `ui.screenshot`: a picture of the window as it is presented, for a control request. The
//! window asks egui for a frame a few frames after the request (a view that just moved settles
//! first), saves it where the request says and answers; a hidden window, a request whose agent
//! chat turn ended, or no frame within 8 s is answered at once with why.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::mpsc::{Receiver, Sender, channel};
use egui::ColorImage;
use serde_json::{Value, json};
use solidcraft_control::{cancelled_chat_response, chat_turn_cancelled};
use crate::window::hidden;
/// A frame asked for: its token, the time (ms) from which it may be taken, and the frames drawn
/// since it was asked for.
pub type Queued = (u64, f64, u32);
/// A request waiting for its frame: its token, where to save it, where to answer, and the time
/// (ms) after which no frame is coming.
pub type Pending = (u64, Option<String>, Sender<Value>, f64);
/// A frame egui handed back, by its token.
pub type Shot = (u64, Arc<ColorImage>);
/// The window's screenshots in flight. The app may queue frames of its own (an issue report's)
/// with tokens from [`Shots::token`]; [`Shots::collect`] hands those back.
pub struct Shots {
/// The error a request gets when the window presents no frame: the app's words, which may
/// name its other ways to a picture.
no_frame: String,
/// The last token given out.
pub token: u64,
/// Frames asked for and not yet requested from egui.
pub queued: Vec<Queued>,
/// Control requests waiting for their frame.
pub pending: Vec<Pending>,
/// The agent chat turn of each pending request that belongs to one.
turns: HashMap<u64, Arc<AtomicBool>>,
/// Frames egui hands back; egui calls back, [`Shots::collect`] reads them.
tx: Sender<Shot>,
rx: Receiver<Shot>,
}
impl Shots {
/// No screenshots in flight; a request the window presents no frame for is answered with the
/// error `no_frame`.
pub fn new(no_frame: impl Into<String>) -> Self {
let (tx, rx) = channel();
Shots { no_frame: no_frame.into(), token: 0, queued: Vec::new(), pending: Vec::new(), turns: HashMap::new(), tx, rx }
}
/// Take the window's picture for a request that answers on `reply`, saved at `path`; `turn`:
/// the agent chat turn it belongs to. `now` is in ms. A window that is minimised or covered
/// (another macOS Space) presents no frame: that is said at once rather than after the
/// deadline (#1677).
pub fn request(&mut self, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>, turn: Option<Arc<AtomicBool>>, now: f64) {
if hidden(ctx) {
let _ = reply.send(json!({"ok": false, "hidden": true, "error": self.no_frame}));
return;
}
self.token += 1;
let token = self.token;
if let Some(turn) = turn {
self.turns.insert(token, turn);
}
self.queued.push((token, now + 150.0, 0));
self.pending.push((token, path, reply, now + 8000.0));
}
/// Each frame: ask egui for the frames that are due, three frames after their request at the
/// earliest. `settled`: the view is drawn at full quality; a view that keeps moving is taken
/// as it is half a second later (#7165).
pub fn issue(&mut self, ctx: &egui::Context, settled: bool, now: f64) {
let shot_tx = &self.tx;
self.queued.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 && (settled || now >= *at + 500.0) {
let (token, tx, ctx2) = (*token, shot_tx.clone(), ctx.clone());
ctx.request_screenshot(move |image| {
let _ = tx.send((token, image));
ctx2.request_repaint();
});
false
} else {
true
}
});
if !self.queued.is_empty() || !self.pending.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// Each frame: answer the requests whose frame came, saving it with `save` (the reply), and
/// those whose turn ended or that wait in vain. `others`: the app waits for frames of its own;
/// then every frame that came this frame is handed back, for the app to take its own from.
pub fn collect(&mut self, ctx: &egui::Context, now: f64, others: bool, save: impl Fn(&ColorImage, Option<&str>) -> Value) -> Vec<Shot> {
// A screenshot requested in one tab must never return or save a frame of another.
let turns = &mut self.turns;
let queued = &mut self.queued;
self.pending.retain(|(token, _, reply, _)| {
if chat_turn_cancelled(turns.get(token).map(Arc::as_ref)) {
turns.remove(token);
queued.retain(|(waiting, ..)| waiting != token);
let _ = reply.send(cancelled_chat_response());
false
} else {
true
}
});
// Drained every frame: a shot that comes after its request was cancelled or timed out
// is dropped here instead of waiting in the channel.
let shots: Vec<Shot> = self.rx.try_iter().collect();
if self.pending.is_empty() && !others {
return Vec::new();
}
for (token, image) in &shots {
if let Some(i) = self.pending.iter().position(|(t, ..)| t == token) {
let (_, path, reply, _) = self.pending.remove(i);
self.turns.remove(token);
let _ = reply.send(save(image, path.as_deref()));
}
}
// Hidden while waiting: no frame is coming.
let hidden = hidden(ctx);
let (turns, no_frame) = (&mut self.turns, &self.no_frame);
self.pending.retain(|(token, _, reply, deadline)| {
if now < *deadline && !hidden {
return true;
}
let _ = reply.send(json!({"ok": false, "error": no_frame}));
turns.remove(token);
false
});
shots
}
}
/// `image` in the format `path`'s extension names (a PNG for `.png`).
pub fn encode(image: &ColorImage, path: &str) -> Result<Vec<u8>, String> {
let [w, h] = image.size;
let mut buf = Vec::with_capacity(w * h * 4);
for c in &image.pixels {
buf.extend_from_slice(&c.to_array());
}
let img = image::RgbaImage::from_raw(w as u32, h as u32, buf).ok_or("bad image")?;
let format = image::ImageFormat::from_path(path).map_err(|e| e.to_string())?;
let mut bytes = std::io::Cursor::new(Vec::new());
img.write_to(&mut bytes, format).map_err(|e| e.to_string())?;
Ok(bytes.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
/// The app's reply for no frame.
const NO_FRAME: &str = "no frame here";
fn pixel_image() -> Arc<ColorImage> {
Arc::new(ColorImage::new([4, 3], vec![egui::Color32::from_rgb(10, 20, 30); 12]))
}
/// A request is answered with its frame once egui hands it back, saved as the app saves it;
/// a frame of the app's own is handed back to it; a request whose frame never comes is
/// answered after its deadline.
#[test]
fn a_request_is_answered_with_its_frame_or_its_deadline() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
shots.request(&ctx, Some("a.png".into()), reply, None, 0.0);
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (1, 1, 1));
// The frame egui hands back, and one the app asked for itself.
shots.queued.clear();
let _ = shots.tx.send((1, pixel_image()));
let _ = shots.tx.send((2, pixel_image()));
let save = |image: &ColorImage, path: Option<&str>| json!({"ok": true, "result": {"path": path, "width": image.size[0]}});
let back = shots.collect(&ctx, 1.0, true, save);
assert_eq!(back.iter().map(|(t, _)| *t).collect::<Vec<_>>(), [1, 2]);
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": true, "result": {"path": "a.png", "width": 4}})));
assert!(shots.pending.is_empty());
let (reply, late) = channel();
shots.request(&ctx, None, reply, None, 0.0);
assert!(shots.collect(&ctx, 7999.0, false, save).is_empty());
assert!(late.try_recv().is_err(), "still waiting");
shots.collect(&ctx, 8000.0, false, save);
assert_eq!(late.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
}
/// A request whose agent chat turn ended is answered so, and its frame is never taken.
#[test]
fn a_request_whose_turn_ended_is_refused() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
let turn = Arc::new(AtomicBool::new(true));
shots.request(&ctx, None, reply, Some(Arc::clone(&turn)), 0.0);
turn.store(false, std::sync::atomic::Ordering::Relaxed);
shots.collect(&ctx, 1.0, false, |_, _| Value::Null);
assert_eq!(answer.try_recv().ok(), Some(cancelled_chat_response()));
assert!(shots.queued.is_empty() && shots.pending.is_empty());
}
/// One frame of a window that is minimised (`hidden`) or shown, running `f`.
fn frame(ctx: &egui::Context, hidden: bool, f: impl FnMut(&egui::Context)) {
let mut raw = egui::RawInput::default();
let info = egui::ViewportInfo { minimized: Some(hidden), occluded: Some(false), ..Default::default() };
raw.viewports.insert(egui::ViewportId::ROOT, info);
// No painter takes the frame's textures here.
ctx.run_pass(raw, f).textures_delta.clear();
}
/// A frame is asked of egui 150 ms after its request at the earliest and on its third frame;
/// a view that keeps moving is taken as it is 500 ms after that.
#[test]
fn a_frame_is_taken_once_the_view_settles() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, _answer) = channel();
shots.request(&ctx, None, reply.clone(), None, 0.0);
shots.issue(&ctx, true, 200.0);
shots.issue(&ctx, true, 200.0);
assert_eq!(shots.queued.len(), 1, "two frames drawn");
shots.issue(&ctx, true, 200.0);
assert!(shots.queued.is_empty(), "the third frame is taken");
shots.request(&ctx, None, reply.clone(), None, 0.0);
for _ in 0..3 {
shots.issue(&ctx, true, 149.0);
}
assert_eq!(shots.queued.len(), 1, "too soon");
shots.issue(&ctx, true, 150.0);
assert!(shots.queued.is_empty());
shots.request(&ctx, None, reply, None, 0.0);
for _ in 0..3 {
shots.issue(&ctx, false, 649.0);
}
assert_eq!(shots.queued.len(), 1, "still moving");
shots.issue(&ctx, false, 650.0);
assert!(shots.queued.is_empty(), "taken as it is");
assert_eq!(shots.pending.len(), 3, "every request still waits for its frame");
}
/// A hidden window refuses a request at once, and answers one that waits when it hides.
#[test]
fn a_hidden_window_answers_at_once() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
frame(&ctx, true, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "hidden": true, "error": NO_FRAME})));
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (0, 0, 0));
frame(&ctx, false, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
frame(&ctx, false, |ctx| {
shots.collect(ctx, 1.0, false, |_, _| Value::Null);
});
assert!(answer.try_recv().is_err(), "shown: still waiting");
frame(&ctx, true, |ctx| {
shots.collect(ctx, 2.0, false, |_, _| Value::Null);
});
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
assert!(shots.pending.is_empty());
}
/// With nothing waiting, the frames that came are dropped, not kept for a later request.
#[test]
fn frames_nobody_waits_for_are_dropped() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let _ = shots.tx.send((5, pixel_image()));
assert!(shots.collect(&ctx, 0.0, false, |_, _| Value::Null).is_empty());
assert!(shots.collect(&ctx, 1.0, true, |_, _| Value::Null).is_empty(), "the frame was dropped");
}
/// The saved bytes are the format the path names.
#[test]
fn a_png_path_gets_a_png() {
let bytes = encode(&pixel_image(), "shot.png").unwrap();
assert!(bytes.starts_with(b"\x89PNG"), "{:?}", bytes.get(..8));
assert!(encode(&pixel_image(), "shot.nope").is_err());
}
}
+19
View File
@@ -0,0 +1,19 @@
//! The window's own control requests over `egui::Context`, which any app answers the same way.
use serde_json::Value;
/// The window is minimised or fully covered (another macOS Space), so it presents no frames
/// (#1677). Unknown counts as shown: the screenshot deadline still ends a wait for a frame that
/// never comes.
pub fn hidden(ctx: &egui::Context) -> bool {
ctx.input(|i| i.viewport().visible()) == Some(false)
}
/// `ui.resize {width, height}`: the window's inner size in points, kept within 320×240 and
/// 8192×8192.
pub fn resize(ctx: &egui::Context, params: &Value) -> Result<Value, String> {
let f = |k: &str| params.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let (Some(w), Some(h)) = (f("width"), f("height")) else { return Err("missing width/height".into()) };
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
Ok(Value::Null)
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "solidcraft-control"
description = "The control channel any app may share: token files, the loopback server, and its requests; no UI toolkit"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
# The loopback server is for desktop apps; the web app is driven through the page instead.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The per-launch control token: 32 random bytes from the operating system (MIT OR Apache-2.0).
getrandom = "0.3"
+23
View File
@@ -0,0 +1,23 @@
# solidcraft-control
The control channel any app may share (docs/control-protocol.md). An app that listens for
control makes a random token per launch, writes it to a file only the user can read and refuses
every connection whose first request doesn't carry it; a client finds the token the same way.
Which app the files belong to, its environment, its name in messages and its default port come
from an `App` value the app defines. No app types and no engine.
## API
| Item | What it does |
|---|---|
| `auth::App` | An app's identity: `name`, `data_dir`, `env` (its prefixed variables), `utc`, `default_port` |
| `App::token_path(port)`, `App::current_port_path()` | `<data folder>/control/<port>.token` and the discovery file (`CONTROL_DIR` replaces the folder) |
| `App::write_token_file(path, token)` | Write a token readable only by the user, with its `<port>.pid` file beside it |
| `App::write_current_port`, `App::read_current_port`, `App::remove_current_port` | The discovery file of the most recently started app |
| `App::env_token()`, `App::client_token(port, explicit)` | The token a client sends: explicit, `CONTROL_TOKEN`, else the token file |
| `auth::ClientToken::explain_error(error)` | A refusal with the token file named and whether it is stale; never the token |
| `auth::plan`, `auth::Listen`, `auth::bind_with_fallback`, `auth::PORT_TRIES` | Where a launch listens; a loopback listener on the first free port |
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
+665
View File
@@ -0,0 +1,665 @@
//! The control channel's per-launch token (docs/control-protocol.md). An app listening for
//! control makes a random token, writes it to a file only the user can read
//! ([`App::token_path`]) and refuses every connection whose first request doesn't carry it, so
//! other users on the same machine (loopback is shared, on Windows across remote-desktop sessions
//! too) can't drive the app. Clients find it with [`App::client_token`]. Next to it, `<port>.pid`
//! names the process that wrote it, so a client refused with a leftover token can say the file is
//! stale.
use std::ffi::OsString;
use std::io::{Error, ErrorKind, Read, Result, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Shortest token accepted from the app's `CONTROL_TOKEN` variable (hex characters).
pub const MIN_TOKEN_LEN: usize = 32;
/// Ports tried from the app's default port upward when it is taken (a second window, another
/// program).
pub const PORT_TRIES: u16 = 16;
/// Which app a control channel belongs to: where its token files live, its environment and its
/// name in messages.
#[derive(Clone, Copy)]
pub struct App {
/// The app's name in messages ("restart SolidCraft").
pub name: &'static str,
/// The per-user data folder; the token files go in its `control` folder.
pub data_dir: fn() -> Option<PathBuf>,
/// The app's environment variable `name` (`CONTROL_DIR`, `CONTROL_TOKEN`), with the app's prefix.
pub env: fn(&str) -> Option<OsString>,
/// A time in seconds since the Unix epoch, as the app writes UTC times in messages.
pub utc: fn(u64) -> String,
/// The port the control channel tries first when the launch gives none. Below the ephemeral
/// ranges of Linux (32768+) and Windows/macOS (49152+), so the system never hands it to an
/// outgoing connection.
pub default_port: u16,
}
/// Where the control channel listens, from the launch's `--control`/environment port and the app's
/// allow-agents setting.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Listen {
/// Exactly this port (`--control PORT`, the app's `CONTROL_PORT`); no fallback.
Exact(u16),
/// The app's [`App::default_port`], or the next free one of [`PORT_TRIES`].
Default,
}
/// What a launch listens on. An explicit port wins over the app's allow-agents setting
/// (`enabled`), so a scripted launch works with the setting off; with the setting off and no port,
/// nothing listens.
pub fn plan(explicit: Option<u16>, enabled: bool) -> Option<Listen> {
match explicit {
Some(port) => Some(Listen::Exact(port)),
None if enabled => Some(Listen::Default),
None => None,
}
}
/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to
/// `tries` ports in all.
pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListener> {
let mut last = Error::new(ErrorKind::AddrInUse, "no port to try");
for port in (first..=u16::MAX).take(usize::from(tries.max(1))) {
match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => return Ok(l),
// In use; Windows reports a port another program holds exclusively as denied.
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e,
Err(e) => return Err(e),
}
}
Err(last)
}
impl App {
/// The folder of the token files: `<data folder>/control` (the app's `CONTROL_DIR` replaces it).
fn control_dir(&self) -> Option<PathBuf> {
match self.custom_control_dir() {
Some(d) => Some(PathBuf::from(d)),
None => Some((self.data_dir)()?.join("control")),
}
}
/// The folder the app's `CONTROL_DIR` names in place of the default one.
fn custom_control_dir(&self) -> Option<OsString> {
(self.env)("CONTROL_DIR").filter(|d| !d.is_empty())
}
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
/// (the app's `CONTROL_DIR` replaces the folder).
pub fn token_path(&self, port: u16) -> Option<PathBuf> {
Some(self.control_dir()?.join(format!("{port}.token")))
}
/// The discovery file: the port of the most recently started app, so a client with no port
/// finds it. It holds only the port; the token stays in its own file.
pub fn current_port_path(&self) -> Option<PathBuf> {
Some(self.control_dir()?.join("current.port"))
}
/// Record `port` as the running app's, readable only by the user like the token files.
pub fn write_current_port(&self, port: u16) -> Result<PathBuf> {
let path = self.current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?;
self.write_token_file(&path, &port.to_string())?;
Ok(path)
}
/// The port the discovery file names, if there is one.
pub fn read_current_port(&self) -> Option<u16> {
parse_port(&std::fs::read_to_string(self.current_port_path()?).ok().filter(|t| t.len() <= 64)?)
}
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
/// over: it names another port, or another launch now holds this port's token file. Under
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
/// number and write the same file names; the one closing first must leave the other's (#8794).
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
pub fn remove_current_port(&self, port: u16, token: &str) {
if let (Some(current), Some(file)) = (self.current_port_path(), self.token_path(port)) {
remove_discovery(&current, &file, port, token);
}
}
/// A token set on purpose with the app's `CONTROL_TOKEN` (test harnesses, sandboxed packages
/// whose data folder the client can't see), if it is long enough.
pub fn env_token(&self) -> Option<String> {
(self.env)("CONTROL_TOKEN").and_then(|v| v.into_string().ok()).map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
}
/// The token a client sends to the app on `port`: `explicit` (`--token`), else the app's
/// `CONTROL_TOKEN`, else the app's token file.
pub fn client_token(&self, port: u16, explicit: Option<&str>) -> Option<ClientToken> {
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
return Some(ClientToken { token: t.to_string(), file: None, app: *self });
}
if let Some(t) = self.env_token() {
return Some(ClientToken { token: t, file: None, app: *self });
}
// Read directly, not through any agent's file scope: the app's own file.
read_token_file(&self.token_path(port)?, *self)
}
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file
/// is removed and no token is written. A file left by an earlier launch is replaced. The process
/// file beside it ([`pid_path`]) records this process, best effort.
pub fn write_token_file(&self, path: &Path, token: &str) -> Result<()> {
#[cfg(windows)]
let restrict = |p: &Path| restrict_to_user(p, self.custom_control_dir().is_some());
#[cfg(not(windows))]
let restrict = |_: &Path| Ok(());
write_token_file_with(path, token, restrict)
}
}
fn parse_port(text: &str) -> Option<u16> {
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
}
/// [`App::remove_current_port`] on given files: `current` is the discovery file, `file` the token
/// file of `port`.
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
if named != Some(port) || held_by_another(file, token) {
return;
}
let _ = std::fs::remove_file(current);
let _ = std::fs::remove_file(pid_path(current));
}
/// A client token and, when it came from a token file, where that file is.
pub struct ClientToken {
pub token: String,
pub file: Option<TokenSource>,
/// The app it is for, named in an explanation.
pub app: App,
}
/// The token file a client read: its path, when it was written and the process that wrote it.
pub struct TokenSource {
pub path: PathBuf,
pub modified: Option<SystemTime>,
/// From `<port>.pid`: the writer's process id and, on Linux, its start time.
pub writer: Option<(u32, Option<u64>)>,
}
impl ClientToken {
/// The app's `error`, and for a refused token read from a file, which file it was, when it
/// was written and whether the instance that wrote it has exited. Never the token itself.
pub fn explain_error(&self, error: &str) -> String {
let Some(src) = &self.file else { return error.to_string() };
if !error.starts_with("unauthorized") {
return error.to_string();
}
let written = src
.modified
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|t| (self.app.utc)(t.as_secs()))
.unwrap_or_else(|| "at an unknown time".into());
let age = src.modified.and_then(|t| t.elapsed().ok()).map(|d| format!(" ({} s ago)", d.as_secs())).unwrap_or_default();
let path = src.path.display();
let name = self.app.name;
match src.writer.filter(|(pid, start)| writer_exited(*pid, *start)) {
Some((pid, _)) => format!(
"{error}; stale token file from an instance that exited (process {pid}): {path}, written {written}{age}; delete that file or restart {name}"
),
None => format!("{error}; token from {path}, written {written}{age}; if {name} was force-quit, restart it or delete that file"),
}
}
}
/// Files bigger than this are not token files.
const MAX_TOKEN_FILE: u64 = 4096;
fn read_token_file(path: &Path, app: App) -> Option<ClientToken> {
let f = std::fs::File::open(path).ok()?;
let modified = f.metadata().ok().and_then(|m| m.modified().ok());
let mut text = String::new();
f.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
if text.len() as u64 > MAX_TOKEN_FILE {
return None;
}
let token = text.trim();
if token.is_empty() {
return None;
}
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let writer = read_pid_file(&pid_path(&path));
Some(ClientToken { token: token.to_string(), file: Some(TokenSource { path, modified, writer }), app })
}
/// The process file beside the token file `path`: `7878.token` → `7878.pid`.
pub fn pid_path(path: &Path) -> PathBuf {
path.with_extension("pid")
}
/// `<pid> [<start time>]`; anything else (or no file) is no information.
fn read_pid_file(path: &Path) -> Option<(u32, Option<u64>)> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(256).read_to_string(&mut text).ok()?;
let mut fields = text.split_whitespace();
let pid = fields.next()?.parse::<u32>().ok().filter(|&pid| pid > 0)?;
Some((pid, fields.next().and_then(|s| s.parse().ok())))
}
/// Whether the process that wrote a token file is known to be gone: not running, or its id now
/// belongs to a process that started at another time. Unknown counts as running.
fn writer_exited(pid: u32, start: Option<u64>) -> bool {
process_running(pid) == Some(false) || start.zip(process_start(pid)).is_some_and(|(saved, now)| saved != now)
}
/// Whether process `pid` runs; `None` when that can't be found out.
fn process_running(pid: u32) -> Option<bool> {
#[cfg(target_os = "linux")]
{
if !Path::new("/proc/self").exists() {
return None;
}
match std::fs::metadata(format!("/proc/{pid}")) {
Ok(_) => Some(true),
Err(e) if e.kind() == ErrorKind::NotFound => Some(false),
Err(_) => None,
}
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().ok()?;
if output.status.success() {
return Some(true);
}
(output.status.code() == Some(1) && output.stdout.iter().all(u8::is_ascii_whitespace)).then_some(false)
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.creation_flags(0x0800_0000)
.output()
.ok()?;
if !output.status.success() || output.stdout.is_empty() {
return None;
}
// `"image","pid",…` per process; with no match, a message line without a pid.
let text = String::from_utf8_lossy(&output.stdout);
Some(text.lines().any(|line| line.split("\",\"").nth(1).and_then(|s| s.trim_matches('"').parse::<u32>().ok()) == Some(pid)))
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
None
}
}
/// Linux: the process's start time (clock ticks after boot), which tells a reused id apart.
fn process_start(pid: u32) -> Option<u64> {
#[cfg(target_os = "linux")]
{
let mut stat = String::new();
std::fs::File::open(format!("/proc/{pid}/stat")).ok()?.take(4096).read_to_string(&mut stat).ok()?;
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
None
}
}
/// `a == b`, taking the same time wherever they differ.
pub fn tokens_match(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// [`App::write_token_file`], with `restrict` limiting the new file to the user (Windows).
fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
std::fs::create_dir_all(dir)?;
let _ = std::fs::remove_file(path);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
opts.mode(0o600);
}
let opened = opts.open(path)?;
// On a refusal the file goes with the closure, so it is closed before it is removed.
let mut f = match restrict(path).map(|()| opened) {
Ok(f) => f,
Err(e) => {
let _ = std::fs::remove_file(path);
return Err(e);
}
};
f.write_all(token.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
let pid = std::process::id();
let line = process_start(pid).map_or_else(|| format!("{pid}\n"), |start| format!("{pid} {start}\n"));
let _ = std::fs::write(pid_path(path), line);
Ok(())
}
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
/// Another launch may have written the same file since (a second window on the same port,
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
/// has the same one, but each launch's token is its own.
pub fn remove_token_file(path: &Path, token: &str) {
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(pid_path(path));
}
}
/// The token a token file holds, trimmed; `None` when there is no readable token file.
fn read_token(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
let token = text.trim();
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
}
/// Whether the token file at `path` holds a token other than `token`: another launch's.
fn held_by_another(path: &Path, token: &str) -> bool {
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
}
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
/// is left suspended (security software holding it), and waiting on it without a limit froze
/// the app.
#[cfg(windows)]
const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
/// How a run of `icacls` ended.
#[cfg(any(windows, test))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum IcaclsEnd {
Succeeded,
Failed,
/// It didn't finish within [`ICACLS_LIMIT`] and was ended.
TimedOut,
}
/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is
/// in the folder the app's `CONTROL_DIR` names. A timeout is accepted only in the default folder,
/// the user's own local app data, which other users can't read by default; nothing is known about
/// the permissions of any other folder.
#[cfg(any(windows, test))]
fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> {
match end {
IcaclsEnd::Succeeded => Ok(()),
IcaclsEnd::TimedOut if !custom_folder => Ok(()),
IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))),
IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))),
}
}
/// Windows: drop inherited entries and grant only the current user full access. An `icacls`
/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means.
#[cfg(windows)]
fn restrict_to_user(path: &Path, custom_folder: bool) -> Result<()> {
use std::os::windows::process::CommandExt;
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
let child = std::process::Command::new("icacls")
.arg(path)
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.creation_flags(0x0800_0000)
.spawn()?;
let end = match wait_within(child, ICACLS_LIMIT)? {
Some(status) if status.success() => IcaclsEnd::Succeeded,
Some(_) => IcaclsEnd::Failed,
None => IcaclsEnd::TimedOut,
};
restriction_result(end, custom_folder, path)
}
/// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it
/// hasn't finished by then.
#[cfg(any(windows, test))]
fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Result<Option<std::process::ExitStatus>> {
let start = std::time::Instant::now();
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
if start.elapsed() >= limit {
// Not waited for afterwards: a suspended process may not end at once.
let _ = child.kill();
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Removes the token file (path, token) when the app ends, while it still holds that token.
pub struct TokenFile(pub PathBuf, pub String);
impl Drop for TokenFile {
fn drop(&mut self) {
remove_token_file(&self.0, &self.1);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// An app with no data folder and no environment, whose times read `t<seconds>`.
const TEST: App = App { name: "TestCraft", data_dir: || None, env: |_| None, utc: |secs| format!("t{secs}"), default_port: 27878 };
/// #6145: a child that doesn't finish in time is given up on, not waited for forever.
#[cfg(unix)]
#[test]
fn a_slow_child_is_waited_for_only_so_long() {
use std::time::{Duration, Instant};
let start = Instant::now();
let slow = std::process::Command::new("sleep").arg("30").spawn().unwrap();
assert!(wait_within(slow, Duration::from_millis(200)).unwrap().is_none());
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed());
let quick = std::process::Command::new("true").spawn().unwrap();
assert!(wait_within(quick, Duration::from_secs(5)).unwrap().is_some_and(|s| s.success()));
let failing = std::process::Command::new("false").spawn().unwrap();
assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success()));
}
/// #6145's freeze fix stays in the default folder, the user's own; a folder picked with
/// the app's `CONTROL_DIR` gets no token unless the file was really restricted.
#[test]
fn only_the_default_folder_accepts_an_icacls_that_timed_out() {
let path = Path::new("control/7878.token");
for custom in [false, true] {
assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok());
assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err());
}
assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok());
let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err();
assert_eq!(e.kind(), ErrorKind::TimedOut);
assert!(e.to_string().contains("7878.token"), "{e}");
}
/// A file that couldn't be restricted is removed, empty and with no process file beside it, and
/// the error goes to the caller. A restricted one gets its token.
#[test]
fn a_token_file_that_could_not_be_restricted_is_removed() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id()));
let path = dir.join("7879.token");
let e = write_token_file_with(&path, "secret-token", |p| {
assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in");
restriction_result(IcaclsEnd::TimedOut, true, p)
})
.unwrap_err();
assert_eq!(e.kind(), ErrorKind::TimedOut);
assert!(!path.exists() && !pid_path(&path).exists());
write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap();
assert_eq!(read_token(&path).as_deref(), Some("secret-token"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tokens_compare_whole() {
assert!(tokens_match("abc123", "abc123"));
assert!(!tokens_match("abc123", "abc124"));
assert!(!tokens_match("abc123", "abc12"));
assert!(!tokens_match("", "a"));
}
/// The preference starts the listener by default, off it doesn't, and a port given on the
/// command line or in the environment wins over it either way.
#[test]
fn the_preference_decides_unless_a_port_is_given() {
assert_eq!(plan(None, true), Some(Listen::Default));
assert_eq!(plan(None, false), None);
assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878)));
assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878)));
}
/// A taken port falls through to the next free one, on loopback only; a run of taken ports
/// is an error, not a hang.
#[test]
fn a_taken_port_falls_back_to_the_next_free_one() {
let first = bind_with_fallback(TEST.default_port, PORT_TRIES).unwrap();
let a = first.local_addr().unwrap();
assert!(a.ip().is_loopback() && (TEST.default_port..TEST.default_port + PORT_TRIES).contains(&a.port()), "{a}");
let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap();
assert!(second.local_addr().unwrap().port() > a.port());
assert!(bind_with_fallback(a.port(), 1).is_err());
}
#[test]
fn the_discovery_file_holds_a_port() {
assert_eq!(parse_port("27879\n"), Some(27879));
assert_eq!(parse_port("0"), None);
assert_eq!(parse_port("70000"), None);
assert_eq!(parse_port("nope"), None);
let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id()));
let path = dir.join("current.port");
TEST.write_token_file(&path, "27881").unwrap();
assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881));
let _ = std::fs::remove_dir_all(&dir);
}
/// A one-line token file (and one with no process file) still reads.
#[test]
fn a_token_file_reads_with_and_without_its_process_file() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-read-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("7879.token");
std::fs::write(&path, "legacy-token\n").unwrap();
let client = read_token_file(&path, TEST).unwrap();
assert_eq!(client.token, "legacy-token");
let src = client.file.as_ref().unwrap();
assert_eq!(src.path, std::fs::canonicalize(&path).unwrap());
assert!(src.modified.is_some() && src.writer.is_none());
std::fs::write(pid_path(&path), "not a pid\n").unwrap();
assert!(read_token_file(&path, TEST).unwrap().file.unwrap().writer.is_none());
std::fs::write(&path, "x".repeat(4097)).unwrap();
assert!(read_token_file(&path, TEST).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
/// A refused token's explanation names the app and writes the time as the app does.
#[test]
fn an_explanation_names_the_app() {
let file = Some(TokenSource { path: PathBuf::from("7878.token"), modified: Some(UNIX_EPOCH), writer: None });
let client = ClientToken { token: "secret-token".into(), file, app: TEST };
let error = client.explain_error("unauthorized");
assert!(error.contains("written t0 (") && error.contains("if TestCraft was force-quit"), "{error}");
assert!(!error.contains("secret-token"), "{error}");
let explicit = TEST.client_token(0, Some("explicit-token")).unwrap();
assert!(explicit.token == "explicit-token" && explicit.file.is_none());
assert_eq!(explicit.explain_error("unauthorized"), "unauthorized");
}
/// A process file naming a process that has exited marks the token file stale.
#[cfg(any(unix, windows))]
#[test]
fn a_token_file_from_an_exited_process_is_stale() {
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "auth::tests::tokens_compare_whole"])
.stdout(std::process::Stdio::null())
.spawn()
.unwrap();
let pid = child.id();
assert!(child.wait().unwrap().success());
let source = |writer| Some(TokenSource { path: PathBuf::from("7878.token"), modified: None, writer });
let stale = ClientToken { token: "secret-token".into(), file: source(Some((pid, None))), app: TEST };
let error = stale.explain_error("unauthorized");
assert!(error.contains(&format!("stale token file from an instance that exited (process {pid}): 7878.token")), "{error}");
assert!(!error.contains("secret-token"));
let live = ClientToken { token: "t".into(), file: source(Some((std::process::id(), process_start(std::process::id())))), app: TEST };
assert!(!live.explain_error("unauthorized").contains("stale"));
#[cfg(target_os = "linux")]
{
let reused = process_start(std::process::id()).map(|s| s + 1);
let reused = ClientToken { token: "t".into(), file: source(Some((std::process::id(), reused))), app: TEST };
assert!(reused.explain_error("unauthorized").contains("stale token file"));
}
}
/// The token file is the user's alone, and a client reads it back.
#[cfg(unix)]
#[test]
fn the_token_file_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("control/7878.token");
std::fs::create_dir_all(dir.join("control")).unwrap();
std::fs::write(&path, "stale").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
TEST.write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
let writer = read_token_file(&path, TEST).unwrap().file.unwrap().writer;
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
assert!(!path.exists() && !pid_path(&path).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
/// loopback) and write the same files. The one closing first leaves the other's token, process
/// and discovery files; the last one takes them all away, `current.pid` too.
#[test]
fn a_closing_launch_leaves_another_launchs_files() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
let (first, second) = ("a".repeat(64), "b".repeat(64));
// The first window, then the second on the same port, each writing both files.
for token in [&first, &second] {
TEST.write_token_file(&file, token).unwrap();
TEST.write_token_file(&current, "27878").unwrap();
}
// The first closes: the second's files stay.
remove_token_file(&file, &first);
remove_discovery(&current, &file, 27878, &first);
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
assert!(pid_path(&file).exists() && current.exists() && pid_path(&current).exists());
// Another port named in the discovery file is another app's too.
remove_discovery(&current, &file, 27879, &second);
assert!(current.exists());
// The second closes: nothing of either is left.
remove_token_file(&file, &format!(" {second}\n"));
remove_discovery(&current, &file, 27878, &second);
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(&current).exists());
// With no token file left (removed by hand, or never written), the discovery file of the
// port still goes; a missing file is no error.
TEST.write_token_file(&current, "27878").unwrap();
remove_token_file(&file, &second);
remove_discovery(&current, &file, 27878, &second);
assert!(!current.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+27
View File
@@ -0,0 +1,27 @@
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
//! #1618): the app's control server and a bridged client both say what the request still does when
//! it finishes. Which requests do what is the app's to say.
/// What a request still running does when it finishes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effect {
/// It only reads (the document, the window or the command list): nothing changes.
ReadsOnly,
/// It writes a file (an export, a save, a render to a path); the document stays as it is.
WritesFile,
/// It changes the document (or may: anything not known to read or only write a file).
Changes,
}
impl Effect {
/// What happens when the request finishes, for a message: "it only reads: nothing changes",
/// "it was not cancelled and will still write the file when it finishes; the design does not
/// change", … `document` is what the app calls its document ("design").
pub fn when_done(self, document: &str) -> String {
match self {
Effect::ReadsOnly => "it only reads: nothing changes".into(),
Effect::WritesFile => format!("it was not cancelled and will still write the file when it finishes; the {document} does not change"),
Effect::Changes => "it was not cancelled and will still apply when it finishes".into(),
}
}
}
+17
View File
@@ -0,0 +1,17 @@
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod auth;
mod effect;
mod request;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
pub use effect::Effect;
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
+118
View File
@@ -0,0 +1,118 @@
//! A control request as the window gets it, and the design watch subscribed connections wait on.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use serde_json::{Value, json};
pub type ControlResponse = Value;
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<ControlResponse>,
/// Set once the window starts on it; until then it waits its turn (`control.running`, #3654).
pub started: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
/// validity follows them through queues, computation and cursor animation.
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
}
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
pub fn refuse_cancelled_chat(&self) -> bool {
if chat_turn_cancelled(self.chat_turn.as_deref()) {
let _ = self.reply.send(cancelled_chat_response());
true
} else {
false
}
}
}
/// Whether the agent chat turn a request belongs to has ended (`None`: it belongs to none).
pub fn chat_turn_cancelled(turn: Option<&std::sync::atomic::AtomicBool>) -> bool {
turn.is_some_and(|active| !active.load(std::sync::atomic::Ordering::Relaxed))
}
/// The reply to a request whose agent chat turn has ended.
pub fn cancelled_chat_response() -> Value {
json!({"ok": false, "error": "not run: the agent chat turn ended or its document tab was left"})
}
/// The active design's id and revision for control connections that sent `control.subscribe`
/// (#6985): the window publishes them each frame while one is subscribed, and each such
/// connection waits for them to move, then sends the app's change event (`Protocol::change_event`).
#[derive(Default)]
pub struct DocumentWatch {
subscribers: AtomicUsize,
now: Mutex<Option<(String, u64)>>,
moved: Condvar,
}
/// The app's [`DocumentWatch`].
pub static DOCUMENT_WATCH: DocumentWatch = DocumentWatch::new();
impl DocumentWatch {
pub const fn new() -> Self {
DocumentWatch { subscribers: AtomicUsize::new(0), now: Mutex::new(None), moved: Condvar::new() }
}
fn now(&self) -> MutexGuard<'_, Option<(String, u64)>> {
self.now.lock().unwrap_or_else(PoisonError::into_inner)
}
/// The design as last published (`None` before anything was).
pub fn current(&self) -> Option<(String, u64)> {
self.now().clone()
}
/// The design as it is now; waiters wake only when it moved. Only the latest is kept, so
/// changes made faster than a connection sends them go out as one.
pub fn publish(&self, document_id: &str, revision: u64) {
let mut now = self.now();
if now.as_ref().is_some_and(|(id, r)| id == document_id && *r == revision) {
return;
}
*now = Some((document_id.to_string(), revision));
self.moved.notify_all();
}
/// Whether a connection is subscribed: the window publishes only then.
pub fn subscribed(&self) -> bool {
self.subscribers.load(Ordering::Relaxed) > 0
}
/// Count a subscribed connection until the returned guard is dropped.
pub fn join(&'static self) -> Subscriber {
self.subscribers.fetch_add(1, Ordering::Relaxed);
Subscriber(self)
}
/// Wait up to `wait` for the design to differ from `seen`, or for `stop`; the design then
/// (`None` before anything was published).
pub fn wait(&self, seen: &(String, u64), stop: &AtomicBool, wait: Duration) -> Option<(String, u64)> {
let unmoved = |now: &mut Option<(String, u64)>| now.as_ref() == Some(seen) && !stop.load(Ordering::Relaxed);
let (now, _) = self.moved.wait_timeout_while(self.now(), wait, unmoved).unwrap_or_else(PoisonError::into_inner);
now.clone()
}
}
/// A subscribed connection, counted by its [`DocumentWatch`] while this lives.
pub struct Subscriber(&'static DocumentWatch);
impl Drop for Subscriber {
fn drop(&mut self) {
self.0.subscribers.fetch_sub(1, Ordering::Relaxed);
// Under the lock, so a connection about to wait sees its `stop` set before it does.
let _now = self.0.now();
self.0.moved.notify_all();
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+1 -1
View File
@@ -154,7 +154,7 @@ impl VisualPreset {
match self.recipe {
Recipe::Opaque(r) => r.color,
// A UI swatch of the transmission values, not rendered glass or absorption.
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_color::srgb::encode8),
}
}
+16
View File
@@ -7021,6 +7021,22 @@ fn replay(
instances.push(start..copies.len());
}
}
// A cut or intersect copy with no named targets, made where the pattern's own component
// has no body for it, works on the bodies of its source's component, as the source did
// (#12996). A pattern whose component has bodies cuts them, as before. The same scope
// counts misses, takes sheet cuts and applies the copies.
let scoped;
let f = if matches!(op, Operation::Cut | Operation::Intersect)
&& targets.is_empty()
&& f.participants.is_empty()
&& src.component != f.component
&& !st.bodies.iter().any(|b| b.component == f.component || b.feature == f.id)
{
scoped = Feature { component: src.component, ..f.clone() };
&scoped
} else {
f
};
if op == Operation::Cut {
let (m, t) = count_copy_misses(st, f, &targets, &copies, &instances, &axes);
missed += m;
+5 -2
View File
@@ -571,9 +571,12 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
if descends(current, name) {
return Some(Match::Piece);
}
// The named piece, told apart by the faces around it: they still bound this piece.
// The named piece, told apart by the faces around it: they still bound this piece. Or the
// face it was a piece of, whole again since, which they still bound: the piece is all of it
// (#11731). Only a piece told apart by faces: a numbered one (`#2`, nothing told it apart)
// says nothing about which part of the whole face it was.
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
&& crate::naming::parent_piece(current) == Some(stem)
&& (crate::naming::parent_piece(current) == Some(stem) || (current == stem && !around.is_empty() && name.ends_with(']')))
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
{
return Some(Match::Piece);
+18
View File
@@ -171,3 +171,21 @@ fn a_linked_designs_face_names_keep_their_pieces_and_nothing_else() {
let _ = role(n);
}
}
/// A face that was split when a name was stored and is whole again (#11731): the stored piece
/// is all of it, when the face still touches the faces that told the piece apart.
#[test]
fn a_piece_name_finds_its_face_once_it_is_whole_again() {
let faces = named(&["top", "left", "right", "start"]);
let names = qualify(faces, &[(0, 1), (0, 3)]);
assert_eq!(names.faces[0], "top");
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
assert_eq!(matches(&names, 0, "top#[left,start]"), Some(Match::Piece));
// It no longer touches a face the qualifier names: some other piece, gone since.
assert_eq!(matches(&names, 0, "top#[right]"), Some(Match::Stem));
assert_eq!(matches(&names, 0, "top#[left]@F9.1"), None, "a copy's piece");
// A numbered piece (nothing told it apart, or a tie) says nothing about which part it was.
assert_eq!(matches(&names, 0, "top#1"), Some(Match::Stem));
assert_eq!(matches(&names, 0, "top#[left]#2"), Some(Match::Stem));
assert_eq!(matches(&names, 0, "top#[]"), Some(Match::Stem));
}
+54
View File
@@ -243,3 +243,57 @@ fn numbered_projection_refuses_a_pick_inside_a_hole_in_the_named_face() {
assert_eq!(pick(5.).map(|(index, _)| index), Some(top));
assert!(pick(0.).is_none(), "the pick is on the plane but inside the hole");
}
/// An edge picked when its side face was split in two keeps the piece in its name
/// (`F2:side:l1#[F2:start]`); once the side is one face again the edge is still found (#11731).
#[test]
fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
let doc = Document {
features: serde_json::from_value(serde_json::json!([
{
"id": 1, "name": "Sketch1", "type": "sketch", "plane": {"name": "XY", "type": "origin"},
"sketch": {
"points": [
{"fixed": true, "id": "origin", "pos": [0., 0.]},
{"fixed": false, "id": "p1", "pos": [0., 0.]},
{"fixed": false, "id": "p2", "pos": [10., 0.]},
{"fixed": false, "id": "p3", "pos": [10., 16.1]},
{"fixed": false, "id": "p4", "pos": [0., 16.1]}
],
"curves": [
{"a": 1, "b": 2, "construction": false, "id": "l1", "type": "line"},
{"a": 2, "b": 3, "construction": false, "id": "l2", "type": "line"},
{"a": 3, "b": 4, "construction": false, "id": "l3", "type": "line"},
{"a": 4, "b": 1, "construction": false, "id": "l4", "type": "line"}
],
"constraints": [],
"counters": {"k": 0, "l": 4, "p": 4}
}
},
{
"id": 2, "name": "Extrude1", "type": "extrude", "body_names": ["Body1"],
"extent": {"convention": "total_distance", "direction": "positive", "distance": "5"},
"operation": "new_body", "profiles": {"loops": [["l1", "l2", "l3", "l4"]], "type": "curves"}, "sketch": 1
}
]))
.unwrap(),
..Document::default()
};
let doc = crate::format::read(&doc.to_json()).unwrap();
let mut model = crate::Model::new();
model.evaluate(&doc);
let state = model.state();
let body = state.body("Body1").unwrap();
// The side along x = 0 meets the start cap in the edge under the pick.
let near = Vec3::new(0., 8.05, 0.);
let names = crate::naming::edge_names(body);
let whole = names.iter().find(|n| n.contains("F2:start") && n.contains("F2:side:l4")).expect("the side's edge on the start cap");
let piece = whole.replace("F2:side:l4", "F2:side:l4#[F2:start]");
let hit = crate::name_lookup::locate_edge(body, &piece, near).unwrap_or_else(|e| panic!("{piece}: {e}"));
assert!(hit.on_point, "the edge under the saved pick");
assert_eq!(hit.point, near);
assert_eq!(names.get(hit.edge), Some(whole));
// A piece told apart by a face the side never touches (the opposite side) is still refused.
let gone = whole.replace("F2:side:l4", "F2:side:l4#[F2:side:l2]");
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
}
+2
View File
@@ -36,6 +36,8 @@ required-features = ["solver-profiling"]
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-control = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
@@ -1552,3 +1552,57 @@ fn join_that_meets_no_body_keeps_the_piece_and_says_so() {
let combine = s.execute("solid.combine", &json!({"target": "A", "tools": ["B"], "operation": "join"})).unwrap();
assert!(combine["warning"].is_null(), "{combine}");
}
/// #12996: a circular pattern of an untargeted cut made in a component, run with another
/// component active, cuts the bodies of the cut's own component, as the cut did.
#[test]
fn pattern_of_an_untargeted_cut_in_a_component_works_with_root_active() {
let mut s = Session::default();
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, &params).unwrap();
run(&mut s, "component.create", json!({"name": "Main Body"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
let one_hole = volume(&s, "Body1");
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Other"}));
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
let hole = 10.0 * 16.0 * std::f64::consts::PI;
let got = volume(&s, "Body1");
assert!((one_hole - got - 9.0 * hole).abs() < 0.02 * hole, "{one_hole} -> {got}");
}
/// #12996: when the component active for the pattern has a body of its own, the copies of an
/// untargeted cut keep cutting it, as they did before: saved designs rebuild the same.
#[test]
fn pattern_of_an_untargeted_cut_still_cuts_its_own_components_body() {
let mut s = Session::default();
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, &params).unwrap();
run(&mut s, "component.create", json!({"name": "Main Body"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
let one_hole = volume(&s, "Body1");
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Other"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Plate2"}));
let plate = volume(&s, "Plate2");
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
let hole = 10.0 * 16.0 * std::f64::consts::PI;
let (body1, plate2) = (volume(&s, "Body1"), volume(&s, "Plate2"));
assert!((one_hole - body1).abs() < 0.02 * hole, "Body1 untouched: {one_hole} -> {body1}");
assert!((plate - plate2 - 9.0 * hole).abs() < 0.02 * hole, "Plate2 cut: {plate} -> {plate2}");
}
+100
View File
@@ -0,0 +1,100 @@
//! Which timeline features built each body (#11945): the data behind `model.body_features` and
//! the Browser's optional features-under-bodies tree.
use std::collections::{BTreeMap, HashMap, HashSet};
use serde_json::Value;
use crate::params::{bad, str_};
use crate::schema::inspect::{BodyFeature, BodyFeatures, ModelBodyFeatures};
use crate::{Result, Session};
impl Session {
/// The features that built each body of the evaluated model, by the body's model name, in
/// timeline order: the feature that created it, every later feature that changed its
/// geometry, and the features of the bodies a feature consumed while it made or changed this
/// one (a Combine's tools, a body joined in). A piece a cut split off keeps the features of
/// the body it was cut from. Features behind the history marker, which built nothing yet,
/// are not listed.
///
/// Decided on exact body identity (a body a feature passed through unchanged is the same
/// record), never on a mesh.
pub fn body_features(&self) -> BTreeMap<String, Vec<u64>> {
let order: HashMap<u64, usize> = self.doc.features.iter().enumerate().map(|(i, f)| (f.id, i)).collect();
// Each body before the feature being looked at: its geometry, its creator and its features.
let mut before: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::new();
for r in &self.model.results {
let id = r.id;
let after = &r.state.bodies;
let names: HashSet<&str> = after.iter().map(|b| b.name.as_str()).collect();
// What the feature consumed: bodies there before and gone after.
let consumed: Vec<u64> =
before.iter().filter(|(name, _)| !names.contains(name.as_str())).flat_map(|(_, (_, _, list))| list.iter().copied()).collect();
let mut next: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::with_capacity(after.len());
for b in after {
let list = match before.get(&b.name) {
// Passed through unchanged.
Some((old, creator, list)) if old.same(&b.body) && *creator == b.feature => list.clone(),
// Changed: it keeps what built it so far.
Some((_, creator, list)) if *creator == b.feature => touched(list.clone(), &consumed, id, &order),
// Made by this feature.
_ if b.feature == id => touched(Vec::new(), &consumed, id, &order),
// A piece of a body made earlier (a split): what built the bodies of its creator.
_ => {
let inherited =
before.values().filter(|(_, creator, _)| *creator == b.feature).flat_map(|(_, _, list)| list.iter().copied()).collect();
touched(inherited, &consumed, id, &order)
}
};
next.insert(b.name.clone(), (b.body.clone(), b.feature, list));
}
before = next;
}
// The model as it stands (a sketch shown live keeps its last results).
self.model
.state()
.bodies
.iter()
.map(|b| (b.name.clone(), before.get(&b.name).map(|(_, _, list)| list.clone()).unwrap_or_else(|| vec![b.feature])))
.collect()
}
}
/// `list` with the features of what was consumed and the feature `id` added, once each, in
/// timeline order.
fn touched(mut list: Vec<u64>, consumed: &[u64], id: u64, order: &HashMap<u64, usize>) -> Vec<u64> {
list.extend(consumed.iter().copied());
list.push(id);
list.sort_by_key(|f| order.get(f).copied().unwrap_or(usize::MAX));
list.dedup();
list
}
/// `model.body_features`: the features that built each body, or one body (a placed copy's name
/// means the body it shows).
pub(super) fn model_body_features(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "model.body_features";
let all = s.body_features();
let wanted = match str_(p, "body") {
Some(name) => {
let st = s.world_state()?;
let b = st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
Some((b.name.clone(), b.definition_name().to_string()))
}
None => None,
};
let feature = |id: &u64| BodyFeature { id: *id, name: s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_default() };
let bodies = match wanted {
Some((shown, definition)) => {
vec![BodyFeatures { body: shown, features: all.get(&definition).map(|l| l.iter().map(feature).collect()).unwrap_or_default() }]
}
None => s
.model
.state()
.bodies
.iter()
.map(|b| BodyFeatures { body: b.name.clone(), features: all.get(&b.name).map(|l| l.iter().map(feature).collect()).unwrap_or_default() })
.collect(),
};
serde_json::to_value(ModelBodyFeatures { bodies, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
}
+91 -43
View File
@@ -19,9 +19,11 @@ use crate::params::{bad, bool_, expr, str_};
use crate::schema::browser::{
BodyRenameParams, BodyRenamed, BrowserFoldParams, BrowserFoldResult, BrowserGroupMade, BrowserGroupParams, BrowserGroupRenamed,
BrowserMoveParams, BrowserMoved, BrowserOrderParams, BrowserOrdered, BrowserRenameGroupParams, BrowserUngroupParams, BrowserUngrouped,
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, RepairDone, RepairFace, RepairHole,
RepairParams, RepairResult, RepairSewn, VisibilityState,
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, PlaneFrame, RedefineAttachment,
RedefineSketchParams, RedefineSketchResult, RepairDone, RepairFace, RepairHole, RepairParams, RepairResult, RepairSewn, ViewApplyParams,
ViewDeleteParams, ViewDeleted, ViewListParams, ViewListResult, ViewRenameParams, ViewRenamed, ViewSaveParams, ViewSaved, VisibilityState,
};
use crate::schema::view_navigation::CameraReply;
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
@@ -35,6 +37,12 @@ const MOVE_CONTRACT: TypedContract<BrowserMoveParams> = TypedContract::new();
const ORDER_CONTRACT: TypedContract<BrowserOrderParams> = TypedContract::new();
const VISIBILITY_CONTRACT: TypedContract<BrowserVisibilityParams> = TypedContract::new();
const FOLD_CONTRACT: TypedContract<BrowserFoldParams> = TypedContract::new();
const REDEFINE_SKETCH_CONTRACT: TypedContract<RedefineSketchParams> = TypedContract::new();
const VIEW_SAVE_CONTRACT: TypedContract<ViewSaveParams> = TypedContract::new();
const VIEW_RENAME_CONTRACT: TypedContract<ViewRenameParams> = TypedContract::new();
const VIEW_DELETE_CONTRACT: TypedContract<ViewDeleteParams> = TypedContract::new();
const VIEW_LIST_CONTRACT: TypedContract<ViewListParams> = TypedContract::new();
const VIEW_APPLY_CONTRACT: TypedContract<ViewApplyParams> = TypedContract::new();
/// A reply as JSON: the commands here build their replies from the types their output schemas
/// describe, so the two can't drift apart.
@@ -56,8 +64,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(
"body: its name (a part the modelling kernel refuses, or an open part that should be a solid); gap?: expr (default 0.2 mm, at most 1 mm: free edges that pair up within it are sewn, each sewn edge carrying its own gap as its tolerance); patch_holes?: bool (default false: loops of free edges left are patched, in the plane of the faces around them where those are that plane, else tangent to them, and sewn in; otherwise the reply offers the patches); name? → the feature, and repair: what changed face by face (rebuilt and refused faces, sewn gaps, holes with their surface.patch), faces and largest edge tolerance before and after, closed, modelling, volume. Refused with why when nothing applies",
),
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", crate::checked!("sketch.redefine", redefine_sketch))
.icon("plane")
.contract(&REDEFINE_SKETCH_CONTRACT)
.output(output_schema_for::<RedefineSketchResult>)
.params("sketch: id or name; plane: as sketch.create; frame?: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
CommandSpec::new("browser.group", "New Group", crate::checked!("browser.group", group))
.icon("folder")
@@ -84,13 +94,33 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&UNITS_CONTRACT)
.output(crate::schema::output_schema_for::<DocumentUnitsResult>)
.params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"),
CommandSpec::new("view.save", "New Named View", view_save)
CommandSpec::new("view.save", "New Named View", crate::checked!("view.save", view_save))
.icon("camera")
.contract(&VIEW_SAVE_CONTRACT)
.output(output_schema_for::<ViewSaved>)
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
CommandSpec::new("view.rename", "Rename Named View", view_rename).icon("rename").params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(),
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
CommandSpec::new("view.rename", "Rename Named View", crate::checked!("view.rename", view_rename))
.icon("rename")
.contract(&VIEW_RENAME_CONTRACT)
.output(output_schema_for::<ViewRenamed>)
.params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", crate::checked!("view.delete", view_delete))
.icon("delete")
.contract(&VIEW_DELETE_CONTRACT)
.output(output_schema_for::<ViewDeleted>)
.params("view: name"),
CommandSpec::new("view.list", "List Named Views", crate::checked!("view.list", view_list))
.icon("list")
.noundo()
.contract(&VIEW_LIST_CONTRACT)
.output(output_schema_for::<ViewListResult>)
.doc_only(),
CommandSpec::new("view.apply", "Restore Named View", crate::checked!("view.apply", view_apply))
.icon("camera")
.noundo()
.contract(&VIEW_APPLY_CONTRACT)
.output(output_schema_for::<CameraReply>)
.params(
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?, roll?} (radians, mm, about the up axis; yaw: heading of the line of sight, 0 along +Y (Front), π/2 along +X (Left), π (Back), −π/2 (Right); pitch: the eye's elevation, within ±π/2, π/2 = Top; roll defaults to 0, turns the picture clockwise, must be finite and is clamped to ±π; the View angles readout shows these in degrees; up is refused: the up axis comes from the design settings) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
),
CommandSpec::new("browser.order", "Order Browser Items", crate::checked!("browser.order", order))
@@ -1049,14 +1079,47 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
reply(kept)
}
/// A camera `view.save` keeps, as the handler reads it (and `ViewCamera`, its contract type): a
/// [`solidcraft_doc::NamedView`] of finite numbers with a positive distance, or the message the
/// handler refuses it with.
pub(crate) fn saved_view_form(c: &Value) -> std::result::Result<solidcraft_doc::NamedView, String> {
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
if ok { Ok(v) } else { Err("the camera must be finite with a positive distance".into()) }
}
/// The camera `view.apply` turns to for a `camera` it is given, as the handler reads it (and
/// `ApplyCamera`, its contract type), or the message the handler refuses it with.
pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<crate::render::Camera, String> {
if c.get("up").is_some() {
return Err("camera `up` is not supported: the up axis comes from the design settings".into());
}
let roll = match c.get("roll") {
Some(value) => value.as_f64().filter(|r| r.is_finite()).ok_or("camera `roll` must be a finite number in radians")?,
None => 0.0,
};
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
return Err("camera `pitch` must be within ±π/2 radians (±90°)".into());
}
let cam = crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) };
if !usable_camera(&cam) {
return Err("the camera must be finite with a positive distance".into());
}
Ok(cam)
}
/// A camera the viewport can be put at: finite, with a positive distance and a field of view of
/// at least 0.
fn usable_camera(cam: &crate::render::Camera) -> bool {
cam.is_valid() && cam.fov.is_finite() && cam.fov >= 0.0
}
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.save";
let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?;
let mut v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
if !ok {
return Err(bad(cmd, "the camera must be finite with a positive distance"));
}
let mut v = saved_view_form(c).map_err(|e| bad(cmd, e))?;
let n = s.doc.named_views.len();
v.name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) if n.len() <= 128 => n.to_string(),
@@ -1070,7 +1133,7 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc_mut();
d.named_views.retain(|x| x.name != name);
d.named_views.push(v);
Ok(json!({ "view": name }))
reply(cmd, ViewSaved { view: name, param_warnings: Vec::new() })
}
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1078,23 +1141,7 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
let cam = match (str_(p, "view"), p.get("camera")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
(None, Some(c)) => {
if c.get("up").is_some() {
return Err(bad(cmd, "camera `up` is not supported: the up axis comes from the design settings"));
}
let roll = match c.get("roll") {
Some(value) => {
value.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(cmd, "camera `roll` must be a finite number in radians"))?
}
None => 0.0,
};
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
return Err(bad(cmd, "camera `pitch` must be within ±π/2 radians (±90°)"));
}
crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) }
}
(None, Some(c)) => applied_camera_form(c).map_err(|e| bad(cmd, e))?,
// Standard view names come first, as in the browser's Named Views folder.
(Some(name), None) => match crate::render::StandardView::parse(name) {
Some(sv) => {
@@ -1106,11 +1153,11 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
},
};
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
if !usable_camera(&cam) {
return Err(bad(cmd, "the camera must be finite with a positive distance"));
}
s.camera = Some(cam);
Ok(json!({ "camera": cam }))
reply(cmd, CameraReply { camera: cam, param_warnings: Vec::new() })
}
/// The orbit camera of a named view (Z up, no roll).
@@ -1134,17 +1181,18 @@ fn view_rename(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = s.doc_mut().named_views.get_mut(i) {
v.name = name.to_string();
}
Ok(json!({ "view": name }))
reply(cmd, ViewRenamed { view: name.to_string(), param_warnings: Vec::new() })
}
fn view_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = view_index(s, p, "view.delete")?;
let cmd = "view.delete";
let i = view_index(s, p, cmd)?;
let v = s.doc_mut().named_views.remove(i);
Ok(json!({ "deleted": v.name }))
reply(cmd, ViewDeleted { deleted: v.name, param_warnings: Vec::new() })
}
fn view_list(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({ "views": s.doc.named_views }))
reply("view.list", ViewListResult { views: s.doc.named_views.clone(), param_warnings: Vec::new() })
}
/// The keys whose strings name bodies, alone or in a list (`body`, `targets`…), at any depth of a
@@ -1493,17 +1541,17 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
"exact_face"
RedefineAttachment::ExactFace
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
"sheet_face"
RedefineAttachment::SheetFace
} else {
"fixed"
RedefineAttachment::Fixed
}),
_ => None,
});
let mut out = json!({ "sketch": id,"attachment":attachment });
super::sketch::frame_reply(&mut out, frame, &placement);
Ok(out)
let note = frame.and_then(|f| super::sketch::frame_note(Some(f), &placement)).map(str::to_string);
let frame = frame.and_then(PlaneFrame::from_name);
reply(cmd, RedefineSketchResult { sketch: id, attachment, frame, note, created: None, param_warnings: Vec::new() })
}
#[cfg(test)]
+47 -19
View File
@@ -4,37 +4,70 @@
use std::collections::BTreeMap;
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
use crate::params::{bad, name_or_id_values, vec3};
use crate::schema::timeline_clipboard::{CopyParams, CopyResult, PasteError, PasteParams, PasteResult, Pasted};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
const PASTE_CONTRACT: TypedContract<PasteParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.copy", "Copy Features", copy).icon("copy").noundo().params("features: [ids or names] (in timeline order on paste)"),
CommandSpec::new("timeline.paste", "Paste Features", paste).icon("paste").params(
"translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \
CommandSpec::new("timeline.copy", "Copy Features", crate::checked!("timeline.copy", copy))
.icon("copy")
.noundo()
.contract(&COPY_CONTRACT)
.output(output_schema_for::<CopyResult>)
.params("features: [ids or names] (in timeline order on paste)"),
CommandSpec::new("timeline.paste", "Paste Features", crate::checked!("timeline.paste", paste))
.icon("paste")
.contract(&PASTE_CONTRACT)
.output(output_schema_for::<PasteResult>)
.params(
"translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \
component_path?: its placement (default: the active one, else its only one); the copies go at the marker, each where \
its original is in the world (copies into their own component keep its coordinates); body transfers are left out; \
from_design?: true to paste sketch features from the host's cross-design clipboard snapshot",
),
),
];
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
/// A command's reply as JSON: they build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// The `features` of `timeline.copy`: named or numbered, both kinds in one list, 1…1000 of them.
/// The reader the command's contract uses too.
pub(crate) fn copy_keys(p: &Value) -> Result<&[Value]> {
let cmd = "timeline.copy";
// The features are named or numbered, and both kinds can sit in one list.
let keys = name_or_id_values(cmd, p, "features")?;
if keys.is_empty() || keys.len() > 1000 {
return Err(bad(cmd, "`features` must list 1…1000 features"));
}
Ok(keys)
}
/// A `component_path` of `timeline.paste`: 0 to 64 occurrence ids. The reader both pastes and the
/// command's contract use.
pub(crate) fn component_path_form(v: &Value) -> Option<Vec<u64>> {
v.as_array().filter(|a| a.len() <= 64).and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.copy";
let keys = copy_keys(p)?;
let ids = keys.iter().map(|key| super::edit::feature_id(s, Some(key), cmd)).collect::<Result<Vec<_>>>()?;
// Keep timeline order so sketches come before the features that use them.
let copied: Vec<solidcraft_doc::Feature> = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect();
let names: Vec<String> = copied.iter().map(|f| f.name.clone()).collect();
s.clipboard = copied;
s.foreign_copy = Some(std::sync::Arc::new(crate::ForeignCopy::of_features(s)));
Ok(json!({"copied": names}))
reply(cmd, CopyResult { copied: names, param_warnings: Vec::new() })
}
/// Origin planes become explicit planes so a translation can move them.
@@ -86,12 +119,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
};
let given_path = match p.get("component_path") {
None => None,
Some(v) => Some(
v.as_array()
.filter(|a| a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?,
),
Some(v) => Some(component_path_form(v).ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?),
};
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
@@ -230,15 +258,15 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
sketches.insert(f.id, id);
names.insert(f.name.clone(), new_name.clone());
made.push(json!({"id": id, "name": new_name, "from": f.name}));
made.push(Pasted { id, name: new_name, from: f.name.clone() });
}
s.refresh();
let errors: Vec<Value> = made
let errors: Vec<PasteError> = made
.iter()
.filter_map(|m| m["id"].as_u64())
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
.map(|m| m.id)
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|error| PasteError { feature: id, error }))
.collect();
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
reply(cmd, PasteResult { pasted: made, errors, notices, param_warnings: Vec::new() })
}
#[cfg(test)]
+6 -8
View File
@@ -2,10 +2,11 @@
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, PlaneRef};
use crate::params::{bad, vec3};
use crate::schema::timeline_clipboard::{PasteResult, Pasted};
use crate::{Result, Sel, Session};
const CMD: &str = "timeline.paste";
@@ -186,11 +187,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let path = match p.get("component_path") {
Some(v) => v
.as_array()
.filter(|a| a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
Some(v) => super::clipboard::component_path_form(v).ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
None if component == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(component)),
None => super::component::placing::placement_of(s, &s.design_document(), component, None, "component_path", CMD)?.0,
};
@@ -249,7 +246,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
ids.insert(feature.id, id);
names.insert(feature.name.clone(), name.clone());
made.push(json!({"id": id, "name": name, "from": feature.name}));
made.push(Pasted { id, name: name.clone(), from: feature.name.clone() });
}
parameters(scratch.doc_mut(), &copy.doc, &copy.features, &mut mapped)?;
let source_values = copy.doc.param_values().0;
@@ -307,5 +304,6 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
scratch.selection = ids.values().map(|id| Sel::Feature { id: *id }).collect();
s.accept_computed(scratch);
Ok(json!({"pasted": made, "errors": [], "notices": []}))
serde_json::to_value(PasteResult { pasted: made, errors: Vec::new(), notices: Vec::new(), param_warnings: Vec::new() })
.map_err(|e| bad(CMD, e.to_string()))
}
+202 -66
View File
@@ -13,17 +13,40 @@ use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::schema::component::{
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams,
MoveSketchesToPartsResult, OccurrenceAlignParams, OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, ChildEntry, CompactPlacement, ComponentNode, CopyParams,
CopyResult, CreateParams, CreateResult, DeleteParams, DeleteResult, DragParams, DragResult, EditInPlaceParams, EditInPlaceResult,
FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, GroundParams, GroundResult, ListParams, ListResult, MoveBodiesParams,
MoveBodiesResult, MoveFeaturesParams, MoveFeaturesResult, MoveParams, MoveResult, MoveSketchesParams, MoveSketchesResult,
MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams, OccurrenceAlignResult, OccurrencePropertiesParams,
OccurrencePropertiesResult, PasteNewParams, PasteNewResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams,
RenameResult, ReparentItemsParams, ReparentItemsResult, RevertPositionParams, RevertPositionResult, UnlockParams, UnlockResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const ACTIVATE_CONTRACT: TypedContract<ActivateParams> = TypedContract::new();
const CREATE_CONTRACT: TypedContract<CreateParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<DeleteParams> = TypedContract::new();
const FROM_BODIES_CONTRACT: TypedContract<FromBodiesParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ListParams> = TypedContract::new();
const PROPERTIES_CONTRACT: TypedContract<PropertiesParams> = TypedContract::new();
const RENAME_CONTRACT: TypedContract<RenameParams> = TypedContract::new();
const UNLOCK_CONTRACT: TypedContract<UnlockParams> = TypedContract::new();
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
const MOVE_SKETCHES_TO_PARTS_CONTRACT: TypedContract<MoveSketchesToPartsParams> = TypedContract::new();
const REPARENT_ITEMS_CONTRACT: TypedContract<ReparentItemsParams> = TypedContract::new();
const CAPTURE_POSITION_CONTRACT: TypedContract<CapturePositionParams> = TypedContract::new();
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
const DRAG_CONTRACT: TypedContract<DragParams> = TypedContract::new();
const GROUND_CONTRACT: TypedContract<GroundParams> = TypedContract::new();
const MOVE_CONTRACT: TypedContract<MoveParams> = TypedContract::new();
const MOVE_BODIES_CONTRACT: TypedContract<MoveBodiesParams> = TypedContract::new();
const MOVE_SKETCHES_CONTRACT: TypedContract<MoveSketchesParams> = TypedContract::new();
const OCCURRENCE_PROPERTIES_CONTRACT: TypedContract<OccurrencePropertiesParams> = TypedContract::new();
const PASTE_NEW_CONTRACT: TypedContract<PasteNewParams> = TypedContract::new();
const REVERT_POSITION_CONTRACT: TypedContract<RevertPositionParams> = TypedContract::new();
#[path = "component_align.rs"]
mod align;
@@ -67,14 +90,23 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.show_history", "Show Component History", show_history)
.icon("eye").doc_only()
.params("component: id or name; puts a component's features back in the History bar after Hide Component History (#10393)"),
CommandSpec::new("component.create", "New Component", new_component)
CommandSpec::new("component.create", "New Component", crate::checked!("component.create", new_component))
.at("SOLID", "ASSEMBLE")
.icon("component")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<CreateResult>)
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true); grounded?: bool (default: true for the design's first part, false otherwise)"),
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
CommandSpec::new("component.from_bodies", "Create Components from Bodies", crate::checked!("component.from_bodies", from_bodies))
.icon("component")
.contract(&FROM_BODIES_CONTRACT)
.output(output_schema_for::<FromBodiesResult>)
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).icon("activate").noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.activate", "Activate Component", crate::checked!("component.activate", activate))
.icon("activate")
.noundo()
.contract(&ACTIVATE_CONTRACT)
.output(output_schema_for::<ActivateResult>)
.params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.edit_in_place", "Edit in Place", crate::checked!("component.edit_in_place", edit_in_place))
.icon("edit")
.noundo()
@@ -99,23 +131,48 @@ pub static COMMANDS: &[CommandSpec] = &[
fails while the design has unsaved changes (run file.save first) or something is open in it; asked during the agent's \
turn it finishes once the turn ends (queued: true); elsewhere nothing changes",
),
CommandSpec::new("component.list", "List Components", list).icon("list").noundo().params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
CommandSpec::new("component.list", "List Components", crate::checked!("component.list", list))
.icon("list")
.noundo()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ListResult>)
.params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
CommandSpec::new("component.properties", "Component Physical Properties", crate::checked!("component.properties", properties::component))
.icon("measure").noundo()
.contract(&PROPERTIES_CONTRACT)
.output(output_schema_for::<PropertiesResult>)
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", crate::checked!("occurrence.properties", properties::occurrence))
.icon("measure").noundo()
.contract(&OCCURRENCE_PROPERTIES_CONTRACT)
.output(output_schema_for::<OccurrencePropertiesResult>)
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params(
CommandSpec::new("component.rename", "Rename Component", crate::checked!("component.rename", rename))
.icon("rename")
.contract(&RENAME_CONTRACT)
.output(output_schema_for::<RenameResult>)
.params("component, name"),
CommandSpec::new("component.delete", "Delete Component", crate::checked!("component.delete", delete))
.icon("delete")
.contract(&DELETE_CONTRACT)
.output(output_schema_for::<DeleteResult>)
.params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", crate::checked!("component.unlock", unlock))
.icon("rigid_group")
.contract(&UNLOCK_CONTRACT)
.output(output_schema_for::<UnlockResult>)
.params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", crate::checked!("component.move_bodies", move_bodies))
.icon("move")
.contract(&MOVE_BODIES_CONTRACT)
.output(output_schema_for::<MoveBodiesResult>)
.params(
"bodies: [names], component; component_path?: its placement to place them by (default: its only one, else the active \
one; a component placed more than once gets them in every instance); from_path?: the placement of their component they \
move from (default: its only one, else its first, where its bodies have their own names)",
@@ -134,8 +191,10 @@ pub static COMMANDS: &[CommandSpec] = &[
A component that uses what isn't in it (another body, sketch or parameter) is refused, saying what. Returns \
`bodies` and `feature`, or the pasted components' `paths`",
),
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches)
CommandSpec::new("component.move_sketches", "Move Sketches to Component", crate::checked!("component.move_sketches", move_sketches))
.icon("move")
.contract(&MOVE_SKETCHES_CONTRACT)
.output(output_schema_for::<MoveSketchesResult>)
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world), except that a sketch on an exactly attached face stays where it is on the face"),
CommandSpec::new(
"component.move_sketches.to_parts",
@@ -171,12 +230,26 @@ pub static COMMANDS: &[CommandSpec] = &[
nothing: placements move first (taking what they hold), then features (taking the bodies they made), then the bodies \
still elsewhere (a body transfer, which leaves its features behind)",
),
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
CommandSpec::new("occurrence.copy", "Paste (Instance)", crate::checked!("occurrence.copy", copy_occurrence))
.icon("copy")
.contract(&COPY_CONTRACT)
.output(output_schema_for::<CopyResult>)
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move")
CommandSpec::new("component.paste_new", "Paste New", crate::checked!("component.paste_new", paste_new))
.icon("paste")
.contract(&PASTE_NEW_CONTRACT)
.output(output_schema_for::<PasteNewResult>)
.params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
CommandSpec::new("occurrence.move", "Move Occurrence", crate::checked!("occurrence.move", move_occurrence))
.icon("move")
.contract(&MOVE_CONTRACT)
.output(output_schema_for::<MoveResult>)
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared. Replies {occurrence, transform, pending}; when joints hold it to something fixed and put it back, transform is where it is and the reply adds reverted: true and a warning naming the joint"),
CommandSpec::new("occurrence.drag", "Drag Occurrence", drag::drag).icon("move").params(
CommandSpec::new("occurrence.drag", "Drag Occurrence", crate::checked!("occurrence.drag", drag::drag))
.icon("move")
.contract(&DRAG_CONTRACT)
.output(output_schema_for::<DragResult>)
.params(
"occurrence or path: exact placement; at: [x,y,z] world point on it; point: [x,y,z] where it goes. Moves the placement by \
point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \
moves stay pending. Shared definitions move in all placements. Grounded parts and parts joints hold to something fixed are refused (joint.reach moves those)",
@@ -191,18 +264,38 @@ pub static COMMANDS: &[CommandSpec] = &[
world coordinates without changing shared component geometry; a placement inside a component placed more than once moves in \
every placement of it, and the reply's `shared` lists those paths",
),
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("occurrence.ground", "Ground", crate::checked!("occurrence.ground", ground))
.icon("ground")
.contract(&GROUND_CONTRACT)
.output(output_schema_for::<GroundResult>)
.params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("occurrence.reparent", "Move to Component", reparent::reparent).icon("move").params(
"occurrence; path?: exact placement; parent: component id or name (0 or \"root\" for the design); parent_path?: the parent's \
placement to place it by (default: its only one, else the active one; every instance of the parent gets it). The placement \
keeps its world position; a move that would break a feature is refused",
),
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
CommandSpec::new("component.capture_position", "Capture Position", crate::checked!("component.capture_position", capture))
.at("SOLID", "POSITION")
.icon("capture")
.contract(&CAPTURE_POSITION_CONTRACT)
.output(output_schema_for::<CapturePositionResult>)
.params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
.at("SOLID", "POSITION")
.icon("revert")
.noundo()
.contract(&REVERT_POSITION_CONTRACT)
.output(output_schema_for::<RevertPositionResult>)
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
];
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
/// A typed reply as the JSON a handler returns.
pub(super) fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
serde_json::to_value(value).map_err(|e| crate::EngineError::Other(e.to_string()))
}
fn key(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::Number(n)) => Some(n.to_string()),
@@ -225,7 +318,7 @@ fn translation(t: Vec3) -> Mat {
solidcraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
}
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
pub(crate) fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
let v = match p.get(key) {
None => default,
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
@@ -288,7 +381,14 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
}
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
let occ = s.doc.occurrence_of(id);
Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ.map(|o| o.id), "grounded": occ.is_some_and(|o| o.grounded)}))
reply(CreateResult {
component: id,
name,
parent,
occurrence: occ.map(|o| o.id),
grounded: occ.is_some_and(|o| o.grounded),
param_warnings: Vec::new(),
})
}
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
@@ -334,7 +434,7 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
s.active_component = id;
s.active_occurrence_path = path;
s.revision += 1;
Ok(json!({ "active": id }))
reply(ActivateResult { active: id, param_warnings: Vec::new() })
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
@@ -342,17 +442,20 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc;
// Compact: what a large assembly needs to be found around in, without every name and matrix.
let compact = bool_(p, "compact").unwrap_or(false);
let names = |v: Vec<&str>| if compact { json!(v.len()) } else { json!(v) };
let names = |v: Vec<&str>| if compact { NamesOrCount::Count(v.len()) } else { NamesOrCount::Names(v.into_iter().map(str::to_string).collect()) };
let body_names = !compact || bool_(p, "bodies").unwrap_or(false);
let placement = |o: &Occurrence| {
if !compact {
return json!(o);
return PlacementEntry::Full(PlacementInfo::from(o));
}
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
v["transform"] = json!(o.transform);
}
v
let moved = o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12);
PlacementEntry::Compact(CompactPlacement {
id: o.id,
name: o.name.clone(),
parent: o.parent,
grounded: o.grounded,
transform: moved.then_some(o.transform),
})
};
// Each component's bodies, features, occurrences and children, grouped once rather than
// searched for every component (thousands of each in a design with many components).
@@ -376,17 +479,37 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let sketches = named(|k| matches!(k, FeatureKind::Sketch { .. }));
let construction = named(|k| matches!(k, FeatureKind::ConstructionPlane { .. }));
let features = named(|k| !matches!(k, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }));
let occurrences: Vec<Value> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
let children: Vec<Value> = children_of.get(&id).into_iter().flatten().map(|o| if compact { json!(o.id) } else { json!(o) }).collect();
let bodies = if body_names { json!(bodies) } else { json!(bodies.len()) };
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": names(features), "sketches": names(sketches),
"construction": names(construction), "occurrences": occurrences, "children": children})
let occurrences: Vec<PlacementEntry> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
let children: Vec<ChildEntry> = children_of
.get(&id)
.into_iter()
.flatten()
.map(|o| if compact { ChildEntry::Id(o.id) } else { ChildEntry::Full(PlacementInfo::from(*o)) })
.collect();
let bodies =
if body_names { NamesOrCount::Names(bodies.into_iter().map(str::to_string).collect()) } else { NamesOrCount::Count(bodies.len()) };
ComponentNode {
id,
name: name.to_string(),
parent,
bodies,
features: names(features),
sketches: names(sketches),
construction: names(construction),
occurrences,
children,
}
};
let mut out = vec![node(0, &doc.name, None)];
for c in &doc.components {
out.push(node(c.id, &c.name, Some(c.parent)));
}
Ok(json!({"active": s.active_component, "components": out, "pending_moves": s.pending_moves.keys().collect::<Vec<_>>()}))
reply(ListResult {
active: s.active_component,
components: out,
pending_moves: s.pending_moves.keys().copied().collect(),
param_warnings: Vec::new(),
})
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
@@ -411,7 +534,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
// A sketch projecting a body of one of those placements keeps it.
super::sketch_project::follow_placed_renames(d, &st, &placed, None);
}
Ok(json!({"component": id, "name": name}))
reply(RenameResult { component: id, name: name.to_string(), param_warnings: Vec::new() })
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -428,7 +551,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if gone.contains(&s.active_component) {
s.active_component = 0;
}
Ok(json!({"deleted": gone, "features": removed.features}))
reply(DeleteResult { deleted: gone, features: removed.features, param_warnings: Vec::new() })
}
fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
@@ -448,7 +571,7 @@ fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
}
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
Ok(json!({"component": id, "joints": gone}))
reply(UnlockResult { component: id, joints: gone, param_warnings: Vec::new() })
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
@@ -547,7 +670,7 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
f.component = to;
}
}
Ok(json!({"sketches": ids, "component": to}))
reply(MoveSketchesResult { sketches: ids, component: to, param_warnings: Vec::new() })
}
pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
@@ -581,7 +704,7 @@ pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
}
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
Ok(json!({"occurrence": id, "component": comp}))
reply(CopyResult { occurrence: id, component: comp, param_warnings: Vec::new() })
}
fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
@@ -711,17 +834,7 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
delta
};
let cur = if let Some(value) = p.get("base_transform") {
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
if !valid_position(&base)
|| base[0][3].abs() > 1e-12
|| base[1][3].abs() > 1e-12
|| base[2][3].abs() > 1e-12
|| (base[3][3] - 1.0).abs() > 1e-12
|| solidcraft_doc::mat_inverse(&base).is_none()
{
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
}
base
base_transform(value).map_err(|message| bad(cmd, message))?
} else {
s.uncaptured_moves().get(&id).copied().unwrap_or(o.transform)
};
@@ -735,24 +848,47 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
x.transform = next;
}
if !s.doc.assembly.joints.is_empty() {
return Ok(after_joints(s, p, id, next, cmd));
return reply(after_joints(s, p, id, next, cmd));
}
} else {
s.pending_moves.insert(id, next);
s.revision += 1;
}
Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)}))
reply(MoveResult {
occurrence: id,
transform: next,
pending: !bool_(p, "capture").unwrap_or(true),
reverted: false,
warning: None,
param_warnings: Vec::new(),
})
}
/// A move's `base_transform`: a 4×4 matrix that is finite, affine and invertible, or what is
/// wrong with it. The parameters contract reads it the same way.
pub(crate) fn base_transform(value: &Value) -> std::result::Result<Mat, &'static str> {
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| "base_transform must be a 4x4 affine matrix")?;
if !valid_position(&base)
|| base[0][3].abs() > 1e-12
|| base[1][3].abs() > 1e-12
|| base[2][3].abs() > 1e-12
|| (base[3][3] - 1.0).abs() > 1e-12
|| solidcraft_doc::mat_inverse(&base).is_none()
{
return Err("base_transform must be finite, affine and invertible");
}
Ok(base)
}
/// The reply to a captured move once the joints are solved (#11723): a part its joints hold to
/// something fixed is put back by that solve, so the reply gives where it is now, says
/// `reverted`, and names the joint and what to change instead.
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> Value {
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> MoveResult {
s.refresh();
let now = s.doc.occurrences.iter().find(|o| o.id == id).map_or(moved, |o| o.transform);
let same = now.iter().flatten().zip(moved.iter().flatten()).all(|(a, b)| (a - b).abs() <= 1e-6);
if same {
return json!({"occurrence": id, "transform": now, "pending": false});
return MoveResult { occurrence: id, transform: now, pending: false, reverted: false, warning: None, param_warnings: Vec::new() };
}
let name = s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
// The placement the caller named, else the first one: a part in a component placed more
@@ -781,7 +917,7 @@ fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> V
"{why}The joints put {name} back where they hold it. To move it, change the joint (joint.edit for its offset or angle, \
joint.drive for a hinge or slider value), move what it is held to, or use joint.reach"
);
json!({"occurrence": id, "transform": now, "pending": false, "reverted": true, "warning": warning})
MoveResult { occurrence: id, transform: now, pending: false, reverted: true, warning: Some(warning), param_warnings: Vec::new() }
}
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
@@ -794,7 +930,7 @@ fn ground(s: &mut Session, p: &Value) -> Result<Value> {
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
o.grounded = want.unwrap_or(!o.grounded);
let g = o.grounded;
Ok(json!({"occurrence": id, "grounded": g}))
reply(GroundResult { occurrence: id, grounded: g, param_warnings: Vec::new() })
}
fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -808,7 +944,7 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
}
}
s.revision += 1;
Ok(json!({ "captured": n }))
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
}
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
@@ -830,7 +966,7 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
}
let n = before - s.pending_moves.len();
s.revision += 1;
Ok(json!({ "reverted": n }))
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
}
/// The active component's frame from world coordinates (for new features' geometry).
+12 -2
View File
@@ -4,8 +4,9 @@ use solidcraft_geom::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use crate::params::bad;
use crate::schema::component::PasteNewResult;
use crate::{Result, Sel, Session};
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
const CMD: &str = "component.paste_new";
@@ -793,7 +794,16 @@ pub(super) fn build(
}
}
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
let out = json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices});
let out = super::component::reply(PasteNewResult {
component: root,
occurrence: root_occurrence,
features: new_ids,
components: components.len(),
bodies: expected_count,
frozen_bodies: frozen,
notices,
param_warnings: Vec::new(),
})?;
Ok((scratch, out))
}
+10 -2
View File
@@ -4,11 +4,12 @@
//! (`joint.reach` moves those as far as their joints allow). A chain of joints joined to nothing
//! fixed moves as one, so its joints stay as they are.
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::{mat_inverse, mat_mul};
use super::{move_vector, occurrence, occurrence_path, translation, valid_position};
use crate::params::bad;
use crate::schema::component::DragResult;
use crate::{EngineError, Result, Session};
#[cfg(test)]
@@ -158,5 +159,12 @@ pub(super) fn drag(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let continuous = p.get("continuous").and_then(Value::as_bool).unwrap_or(false);
Ok(json!({"occurrence": path.last(), "path": path, "moved": moved, "translate": [t.x, t.y, t.z], "continuous": continuous}))
super::reply(DragResult {
occurrence: path.last().copied().unwrap_or_default(),
path,
moved,
translate: [t.x, t.y, t.z],
continuous,
param_warnings: Vec::new(),
})
}
+52 -28
View File
@@ -2,13 +2,16 @@
use solidcraft_geom::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_doc::{Document, Mat};
use solidcraft_geom::Aabb3;
use solidcraft_geom::inertia::{self, Tensor3};
use crate::params::bad;
use crate::schema::component::{
BodyProperties, BoundingBox, InertiaAbout, OccurrencePropertiesResult, PhysicalProperties, PropertiesResult, TotalProperties,
};
use crate::{Result, Session};
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
@@ -84,10 +87,8 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
} else {
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["component"] = json!(id);
out["name"] = json!(name);
Ok(out)
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
super::reply(PropertiesResult { component: id, name, properties, param_warnings: Vec::new() })
}
pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
@@ -105,10 +106,9 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
}
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["occurrence"] = json!(id);
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
Ok(out)
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
let name = doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone());
super::reply(OccurrencePropertiesResult { occurrence: id, name, properties, param_warnings: Vec::new() })
}
/// The linear part of `m` is a rotation (no scale, shear or mirror), to rounding.
@@ -200,7 +200,7 @@ fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result<Pl
})
}
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
fn measure(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<PhysicalProperties> {
let state = s.model.state();
let mut bodies = Vec::new();
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
@@ -292,13 +292,20 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
faces += measured.faces;
edges += measured.edges;
vertices += measured.vertices;
bodies.push(json!({"name": body.name, "component": component, "path": path,
"material": doc.body_material(&body.name),
"closed": closed, "density_g_cm3": closed.then_some(density),
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
"inertia_g_mm2": body_inertia,
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
bodies.push(BodyProperties {
name: body.name.to_string(),
component,
path: path.clone(),
material: doc.body_material(&body.name),
closed,
density_g_cm3: closed.then_some(density),
mass_g: closed.then_some(body_mass),
volume_mm3: closed.then_some(measured.volume),
area_mm2: measured.area,
center_of_mass: closed.then_some(measured.centroid),
inertia_g_mm2: body_inertia,
bbox: BoundingBox { min: measured.bbox.min, max: measured.bbox.max },
});
}
}
if let Some(w) = &watch {
@@ -321,19 +328,36 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
});
let at_centre = at_centre.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
let principal = at_centre.and_then(|i| inertia::principal(&i, EQUAL_MOMENTS));
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": bounds.min, "max": bounds.max}) };
let mut out = json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
"total": {"open_bodies_skipped": skipped, "volume_mm3": volume, "area_mm2": area, "mass_g": mass,
"center_of_mass": centroid, "first_moment_g_mm": moment, "inertia_g_mm2": at_centre,
"principal_moments_g_mm2": principal.map(|(moments, _)| moments), "principal_axes": principal.map(|(_, axes)| axes),
"bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}});
if let Some(point) = about {
let bbox = (!bodies.is_empty()).then_some(BoundingBox { min: bounds.min, max: bounds.max });
let inertia_about = about.map(|point| {
let shifted =
centroid.zip(at_centre).map(|(c, i)| inertia::shifted(&i, mass, point - c)).filter(|i| i.iter().flatten().all(|x| x.is_finite()));
out["total"]["inertia_about"] = json!({"point": point, "inertia_g_mm2": shifted});
}
Ok(out)
InertiaAbout { point, inertia_g_mm2: shifted }
});
Ok(PhysicalProperties {
path: selected.to_vec(),
coordinate_frame: "world".to_string(),
descendants,
placement_count,
body_count: bodies.len(),
bodies,
total: TotalProperties {
open_bodies_skipped: skipped,
volume_mm3: volume,
area_mm2: area,
mass_g: mass,
center_of_mass: centroid,
first_moment_g_mm: moment,
inertia_g_mm2: at_centre,
principal_moments_g_mm2: principal.map(|(moments, _)| moments),
principal_axes: principal.map(|(_, axes)| axes),
bbox,
faces,
edges,
vertices,
inertia_about,
},
})
}
/// The tessellating work a properties command did on this thread, for tests: placed copies
+4 -3
View File
@@ -1,5 +1,6 @@
//! Atomic timeline ownership changes, preserving the selected world geometry.
use super::*;
use crate::schema::component::{BodyComponent, FromBodiesResult, MoveBodiesResult};
use solidcraft_doc::BodyName;
use std::collections::{BTreeMap, BTreeSet};
@@ -128,10 +129,10 @@ pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
for (name, component) in bodies.into_iter().zip(targets) {
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
features.push(feature);
made.push(json!({"component":component,"body":name,"feature":feature}));
made.push(BodyComponent { component, body: name, feature });
}
finish(s, &mut scratch, &features, cmd)?;
Ok(json!({"components":made}))
super::reply(FromBodiesResult { components: made, param_warnings: Vec::new() })
}
/// A placement path parameter (`component_path`: the target's placement to place by,
@@ -187,7 +188,7 @@ fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
}
finish(s, &mut scratch, &features, cmd)?;
Ok(json!({"bodies":bodies,"component":to,"features":features}))
super::reply(MoveBodiesResult { bodies, component: to, features, param_warnings: Vec::new() })
}
#[cfg(test)]
+102 -60
View File
@@ -8,13 +8,17 @@ use solidcraft_doc::{Feature, FeatureKind};
use super::CommandSpec;
use crate::params::{bad, bool_, name_or_id_values, num, str_};
use crate::schema::TypedContract;
use crate::schema::edit::{
FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams, TimelineComputeAllResult,
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
BrokenFeature, FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams,
TimelineComputeAllResult, TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams,
TimelineEditResult, TimelineGroupMade, TimelineGroupParams, TimelineGroupRenamed, TimelineInspectParams, TimelineInspectResult,
TimelineMarkerResult, TimelineMoveManyParams, TimelineMoveToGroupParams, TimelineMoved, TimelineMovedToGroup, TimelineRedefineParams,
TimelineRedefined, TimelineRenameGroupParams, TimelineRenameParams, TimelineRenameResult, TimelineReorderManyParams, TimelineReorderParams,
TimelineReordered, TimelineReorderedMany, TimelineRollToParams, TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
TimelineUngroupParams, TimelineUngrouped,
};
use crate::schema::select::{PartialPick, SelectClearParams, SelectClearResult, SelectItem, SelectSetParams, SelectSetResult, read_item};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
@@ -87,18 +91,35 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&RENAME_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineRenameResult>)
.params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.group", "New Feature Folder", group)
CommandSpec::new("timeline.group", "New Feature Folder", crate::checked!("timeline.group", group))
.icon("folder")
.doc_only()
.contract(&GROUP_CONTRACT)
.output(output_schema_for::<TimelineGroupMade>)
.params("name?; features?: [id|name]; joints?: [id|name]; groups?: [folder ids]; parent?: folder id (features and joints leave any other timeline folder)"),
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder")
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", crate::checked!("timeline.ungroup", ungroup))
.icon("folder")
.doc_only()
.contract(&UNGROUP_CONTRACT)
.output(output_schema_for::<TimelineUngrouped>)
.params("group: id | groups: [ids] (features and joints remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name (renames the folder holding features and joints)"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder")
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", crate::checked!("timeline.rename_group", rename_group))
.icon("rename")
.doc_only()
.contract(&RENAME_GROUP_CONTRACT)
.output(output_schema_for::<TimelineGroupRenamed>)
.params("group: id, name (renames the folder holding features and joints)"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", crate::checked!("timeline.move_to_group", move_to_group))
.icon("folder")
.doc_only()
.contract(&MOVE_TO_GROUP_CONTRACT)
.output(output_schema_for::<TimelineMovedToGroup>)
.params("features?: [id|name], joints?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
CommandSpec::new("timeline.move_many", "Move Timeline Items", crate::checked!("timeline.move_many", move_many))
.icon("reorder")
.contract(&MOVE_MANY_CONTRACT)
.output(output_schema_for::<TimelineMoved>)
.params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", crate::checked!("timeline.rollback", rollback))
@@ -111,12 +132,22 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ROLL_TO_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
CommandSpec::new("timeline.reorder", "Reorder Feature", crate::checked!("timeline.reorder", reorder))
.icon("reorder")
.contract(&REORDER_CONTRACT)
.output(output_schema_for::<TimelineReordered>)
.params("feature: id|name, position: its new index, active?: bool (true = it ends up before the history marker, false = behind it; other features keep their state); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke → feature, position: its index now, from: its index before, moved: bool, note?: why nothing moved, or that a position past the end meant the last slot"),
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
CommandSpec::new("timeline.reorder_many", "Reorder Features", crate::checked!("timeline.reorder_many", reorder_many))
.icon("reorder")
.contract(&REORDER_MANY_CONTRACT)
.output(output_schema_for::<TimelineReorderedMany>)
.params(
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; active?: bool (as timeline.reorder's); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke",
),
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
CommandSpec::new("timeline.redefine", "Edit Feature", crate::checked!("timeline.redefine", redefine))
.icon("edit")
.contract(&REDEFINE_CONTRACT)
.output(output_schema_for::<TimelineRedefined>)
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
CommandSpec::new("timeline.dependents", "Feature Dependents", crate::checked!("timeline.dependents", dependents))
.icon("graph")
@@ -152,16 +183,24 @@ pub static COMMANDS: &[CommandSpec] = &[
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
CommandSpec::new("select.set", "Select", select_set).icon("select")
CommandSpec::new("select.set", "Select", crate::checked!("select.set", select_set)).icon("select")
.noundo()
.contract(&SELECT_SET_CONTRACT)
.output(output_schema_for::<SelectSetResult>)
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool; face/edge: body plus point [x,y,z] in world mm or index (both also accepted); a point uses Measure's pick tolerance, an index supplies a point on the item; invalid partial picks leave the selection unchanged"),
CommandSpec::new("select.filter", "Selection filter", select_filter).icon("select").noundo().doc_only()
.params("mode: body|face|component → validated UI pick granularity; headless echo only, no document edits or undo"),
CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(),
CommandSpec::new("select.clear", "Clear Selection", crate::checked!("select.clear", select_clear))
.icon("select")
.noundo()
.contract(&SELECT_CLEAR_CONTRACT)
.output(output_schema_for::<SelectClearResult>),
CommandSpec::new("select.same_look", "Select Same Look", select_same_look).icon("select").noundo().doc_only()
.params("body?: world body name, face?: face index; defaults to the current body/face selection → selects objects using the resolved appearance"),
];
const SELECT_SET_CONTRACT: TypedContract<SelectSetParams> = TypedContract::new();
const SELECT_CLEAR_CONTRACT: TypedContract<SelectClearParams> = TypedContract::new();
const COMPUTE_ALL_CONTRACT: TypedContract<TimelineComputeAllParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<TimelineDeleteParams> = TypedContract::new();
const RENAME_CONTRACT: TypedContract<TimelineRenameParams> = TypedContract::new();
@@ -171,6 +210,14 @@ const DEPENDENTS_CONTRACT: TypedContract<TimelineDependentsParams> = TypedContra
const SUPPRESS_CONTRACT: TypedContract<TimelineSuppressParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<TimelineEditParams> = TypedContract::new();
const INSPECT_CONTRACT: TypedContract<TimelineInspectParams> = TypedContract::new();
const GROUP_CONTRACT: TypedContract<TimelineGroupParams> = TypedContract::new();
const UNGROUP_CONTRACT: TypedContract<TimelineUngroupParams> = TypedContract::new();
const RENAME_GROUP_CONTRACT: TypedContract<TimelineRenameGroupParams> = TypedContract::new();
const MOVE_TO_GROUP_CONTRACT: TypedContract<TimelineMoveToGroupParams> = TypedContract::new();
const MOVE_MANY_CONTRACT: TypedContract<TimelineMoveManyParams> = TypedContract::new();
const REORDER_CONTRACT: TypedContract<TimelineReorderParams> = TypedContract::new();
const REORDER_MANY_CONTRACT: TypedContract<TimelineReorderManyParams> = TypedContract::new();
const REDEFINE_CONTRACT: TypedContract<TimelineRedefineParams> = TypedContract::new();
const PLAY_CONTRACT: crate::schema::TypedContract<crate::schema::edit::TimelinePlayParams> = crate::schema::TypedContract::new();
fn play(_s: &mut Session, p: &Value) -> Result<Value> {
@@ -608,7 +655,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
return Err(not_contiguous(&folders, broken, cmd));
}
s.doc_mut().timeline_groups = folders;
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
reply(cmd, TimelineGroupMade { group: id, name, parent, groups, param_warnings: Vec::new() })
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
@@ -665,7 +712,7 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"groups": ids}))
reply(cmd, TimelineUngrouped { groups: ids, param_warnings: Vec::new() })
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
@@ -678,7 +725,7 @@ fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
group.name = name.to_string();
}
Ok(json!({"group": id, "name": name}))
reply(cmd, TimelineGroupRenamed { group: id, name: name.to_string(), param_warnings: Vec::new() })
}
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
@@ -715,7 +762,7 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"features": features, "joints": joints, "groups": groups, "group": target}))
reply(cmd, TimelineMovedToGroup { features, joints, groups, group: target, param_warnings: Vec::new() })
}
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
@@ -761,7 +808,7 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
}
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
reply(cmd, TimelineMoved { features: ordered, groups, group: target, position: slot, param_warnings: Vec::new() })
}
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1408,20 +1455,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
/// an existing index-and-point pair. Unlike Measure, selection keeps the exact face/edge index:
/// it must not merge a cylinder's patches or a circular rim's arcs into a different item.
fn selection_item(s: &Session, value: Value) -> Result<Sel> {
use solidcore_record::Vec3;
let kind = value.get("type").and_then(Value::as_str);
if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) {
return serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()));
}
let face = kind == Some("face");
#[derive(serde::Deserialize)]
struct Pick {
body: String,
index: Option<usize>,
point: Option<Vec3>,
}
let Pick { body, index, point } = serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()))?;
let (face, PartialPick { body, index, point }) = match read_item(value).map_err(|e| bad("select.set", e))? {
SelectItem::Exact(sel) => return Ok(sel),
SelectItem::Pick { face, pick } => (face, pick),
};
let state = s.world_state()?;
let b = state.body(&body).ok_or_else(|| bad("select.set", format!("no body `{body}`")))?;
let missing = || bad("select.set", format!("no {} of `{body}` at the given index or point", if face { "face" } else { "edge" }));
@@ -1496,7 +1533,7 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
s.refresh_display();
Ok(json!({"selected": s.selection.len()}))
reply("select.set", SelectSetResult { selected: s.selection.len(), param_warnings: Vec::new() })
}
fn select_filter(_s: &mut Session, p: &Value) -> Result<Value> {
@@ -1553,7 +1590,7 @@ fn select_same_look(s: &mut Session, p: &Value) -> Result<Value> {
fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
s.selection.clear();
s.refresh_display();
Ok(json!({"selected": 0}))
reply("select.clear", SelectClearResult { selected: 0, param_warnings: Vec::new() })
}
/// Features that worked in `before` and fail in `after`: (name, error).
@@ -1660,7 +1697,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let active = active_param(p, cmd)?;
if from == to && active.is_none() {
let why = capped.map_or_else(|| format!("it is already at position {to}"), |c| format!("{c}, where it already is"));
return Ok(json!({"feature": id, "position": to, "from": from, "moved": false, "note": format!("nothing moved: {why}")}));
let note = Some(format!("nothing moved: {why}"));
return reply(cmd, TimelineReordered { feature: id, position: to, from, moved: false, note, broken: None, param_warnings: Vec::new() });
}
let mut next = (*s.doc).clone();
let f = next.features.remove(from);
@@ -1705,20 +1743,22 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
}
*s.doc_mut() = next;
}
let mut out = json!({"feature": id, "position": now, "from": from, "moved": now != from});
// With `active`, a feature left in place may still cross the history marker: say which.
let stayed = (now == from).then(|| {
let what = if s.doc.marker == marker { "nothing moved" } else { "only the history marker moved" };
format!("{what}: {name} is already at position {now}")
});
let note: Vec<String> = [capped, stayed].into_iter().flatten().collect();
if !note.is_empty() {
out["note"] = json!(note.join("; "));
}
if allow {
out["broken"] = broken_json(&broken);
}
Ok(out)
let out = TimelineReordered {
feature: id,
position: now,
from,
moved: now != from,
note: (!note.is_empty()).then(|| note.join("; ")),
broken: allow.then(|| broken_features(&broken)),
param_warnings: Vec::new(),
};
reply(cmd, out)
}
/// The optional `allow_errors` of a move: make it even when it breaks features that worked.
@@ -1731,8 +1771,8 @@ fn allow_errors(p: &Value, cmd: &str) -> Result<bool> {
}
/// The features a move broke, as `[{feature, error}]`.
fn broken_json(broken: &[(String, String)]) -> Value {
broken.iter().map(|(feature, error)| json!({"feature": feature, "error": error})).collect()
fn broken_features(broken: &[(String, String)]) -> Vec<BrokenFeature> {
broken.iter().map(|(feature, error)| BrokenFeature { feature: feature.clone(), error: error.clone() }).collect()
}
/// The optional `active` of a move: whether the moved features end up before the history marker.
@@ -1778,11 +1818,8 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
}
*s.doc_mut() = next;
}
let mut out = json!({"features": ordered, "position": slot.min(s.doc.features.len())});
if allow {
out["broken"] = broken_json(&broken);
}
Ok(out)
let position = slot.min(s.doc.features.len());
reply(cmd, TimelineReorderedMany { features: ordered, position, broken: allow.then(|| broken_features(&broken)), param_warnings: Vec::new() })
}
/// The reordered design, the moved features in order, and the model the move was checked on
@@ -2093,15 +2130,20 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if rolled_back { r.get("warning").and_then(Value::as_str).map(str::to_string) } else { s.model.result(id).and_then(|r| r.warning.clone()) };
// Report the trial's rebuild, plus anything the session still had to compute itself (a kept
// result its budget dropped); `s.model`'s own counters would only report the reuse (#5694).
let mut reply = json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused, "rolled_back": rolled_back});
if let Some(warning) = warning {
reply["warning"] = json!(warning);
}
if rolled_back {
let note = rolled_back.then(|| {
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
reply["note"] = json!(format!("{name} is after the history marker, so it is not built: move the marker past it to build it"));
}
Ok(reply)
format!("{name} is after the history marker, so it is not built: move the marker past it to build it")
});
let out = TimelineRedefined {
feature: id,
recomputed: rebuilt + s.model.last_recomputed,
restored: reused,
rolled_back,
warning,
note,
param_warnings: Vec::new(),
};
reply(cmd, out)
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
+355 -67
View File
@@ -87,8 +87,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("F")
.contract(&FILLET_CONTRACT)
.output(output_schema_for::<FilletResult>)
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them); type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
.at("SOLID", "MODIFY")
.icon("combine")
@@ -177,7 +177,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet); body?: the body the face or edges are on (for edges, as for solid.fillet)"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place); occurrences?: [occurrence paths, root first] (only with copy: each component is copied as an independent component, as Paste New copies it, and the copy moves by the same translate, axis and angle in the world; the reply lists the copies in occurrences)"),
];
@@ -1554,20 +1554,131 @@ fn prim_cone(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::Cone { base, axis: Dir(axis), radius, top_radius, height, operation: operation(p, cmd)? })
}
/// Edge references → points on the edges.
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
/// What [`edge_points`] found: a point on each edge, in its body's frame, and the body the
/// feature keeps (`None`: each edge is looked up on its own).
struct EdgePicks {
points: Vec<Vec3>,
body: Option<String>,
}
/// A body as a pick is looked up on: the model body, the name it is shown by, and the frame it
/// is placed by and its inverse.
struct PickRow {
body: usize,
name: String,
frame: solidcraft_doc::Mat,
inverse: solidcraft_doc::Mat,
}
/// What one body's edges near a pick are: the point in its frame, its distance from the pick on
/// the exact curves and the edge there, whether any edge within reach is not a seam, and the
/// edges within reach (polyline distance, seam, the direction leaving).
struct RowHit {
row: usize,
q: Vec3,
d: f64,
edge: Option<usize>,
edge_like: bool,
near: Vec<(f64, bool, Option<Vec3>)>,
}
/// How many bodies a refusal names before "and N more".
const NAMED_BODIES: usize = 8;
/// A body's size, its edges' polylines and which of them are seams.
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>);
/// How far a polyline is from `q`.
fn polyline_dist(points: &[Vec3], q: Vec3) -> f64 {
points.windows(2).filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min)
}
/// How far from an edge of a body this big a pick still means it.
fn edge_reach(size: f64) -> f64 {
size * 0.05 + 1e-3
}
/// Edge references → points on the edges, each in the frame of the body it lies on.
///
/// A point is looked up where it was picked, on every placed body, shown or hidden: a part
/// whose edge is in the same place only in its own frame is not in the way, and two bodies
/// with an edge there make it ambiguous. The polylines short-list the edges; which body a pick
/// means is decided on the exact curves.
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`edges` must be a list"))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "select 1…1000 edges"));
}
let verb = if cmd == "solid.chamfer" { "chamfer" } else { "round" };
let st = s.model.state();
// A picked point must lie on an edge (within the distance the kernel re-finds an edge from)
// of the body the feature names, or of any body: a pick far from every edge would round
// whichever edge is nearest (#783). Each body's edges are worked out once.
let named = str_(p, "body").filter(|n| st.body(n).is_some());
// Per body: its size, its edges' polylines and which of them are seams.
let mut candidates: Option<Vec<(f64, Vec<Vec<Vec3>>, Vec<bool>)>> = None;
// whichever edge is nearest (#783).
let given = str_(p, "body");
let named = given.and_then(|n| st.bodies.iter().position(|b| b.name == n));
// A pick's own body is the feature's, which keeps one.
let agrees = |i: usize| -> Result<usize> {
match given {
Some(g) if named != Some(i) => {
let n = st.bodies.get(i).map_or("", |b| b.name.as_str());
Err(bad(cmd, format!("an edge names `{n}`, but `body` is `{g}`: give one")))
}
_ => Ok(i),
}
};
let identity = |i: usize, b: &solidcraft_doc::ModelBody| PickRow {
body: i,
name: b.name.clone(),
frame: solidcraft_doc::IDENTITY,
inverse: solidcraft_doc::IDENTITY,
};
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
let local: Vec<PickRow> = st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()).map(|(i, b)| identity(i, b)).collect();
let placed = crate::frames::placed(s);
// Worked out for the first pick looked up where it was picked.
let world: std::cell::OnceCell<Vec<PickRow>> = std::cell::OnceCell::new();
let world_rows = || {
world.get_or_init(|| {
let index: std::collections::BTreeMap<&str, usize> = st.bodies.iter().enumerate().map(|(i, b)| (b.definition_name(), i)).collect();
s.body_frames()
.iter()
.filter_map(|(name, own, frame)| {
let body = *index.get(own.as_str())?;
let inverse = solidcraft_doc::mat_inverse(frame)?;
(!st.bodies.get(body)?.body.is_mesh()).then(|| PickRow { body, name: name.clone(), frame: *frame, inverse })
})
.collect()
})
};
// Per body, worked out once.
let mut shapes: std::collections::BTreeMap<usize, EdgeShape> = std::collections::BTreeMap::new();
let shape_of = |i: usize, shapes: &mut std::collections::BTreeMap<usize, EdgeShape>| {
if shapes.contains_key(&i) {
return;
}
let Some(b) = st.bodies.get(i) else { return };
let size = b.body.size();
let tol = (size * 2e-3).max(1e-3);
// The display mesh flags seams (a smooth surface meeting itself, as down the side
// of a cylinder), which nobody means by a pick where they end.
let shape = match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
// An edge where the faces meet tangentially (the spans of a spline wall
// meeting) can't be blended either: nobody means it by a pick.
Ok(m) => Some((
size,
m.edges,
m.seams.iter().zip(&m.smooth_edges).map(|(s, t)| *s || *t).chain(m.seams.iter().skip(m.smooth_edges.len()).copied()).collect(),
)),
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
};
if let Some(shape) = shape {
shapes.insert(i, shape);
}
};
let mut out = Vec::new();
// Per pick: the body it was found on, where it was picked, and how far from that body's edge.
let mut found: Vec<(usize, String, Vec3, Vec3, f64)> = Vec::new();
let mut seen: Vec<Vec3> = Vec::new();
for e in a {
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
EdgeRefForm::Point(pt) => pt,
@@ -1576,57 +1687,139 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let tol = (b.body.size() * 2e-3).max(1e-3);
let edges = b.body.edges(tol)?;
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
let mid = edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid;
if let Some(i) = st.bodies.iter().position(|m| m.name == b.name) {
found.push((agrees(i)?, b.name.clone(), mid, mid, 0.0));
}
out.push(mid);
continue;
}
};
let bodies = candidates.get_or_insert_with(|| {
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
st.bodies
// The nth pick seen at this point is the nth remembered there.
let nth = seen.iter().filter(|q| **q == pt).count();
seen.push(pt);
let picked = crate::frames::picked_world(pt, nth).filter(|_| placed);
let shown = picked.unwrap_or(pt);
let own = match e.get("body").and_then(Value::as_str) {
Some(n) => Some(agrees(st.bodies.iter().position(|b| b.name == n).ok_or_else(|| bad(cmd, format!("no body `{n}`")))?)?),
None => named,
};
// Named: on that body only, in its frame. Else where it was picked, on every placed body;
// a command run by another without the world point, or a design with no placement to look
// through, sees the bodies in their own frames.
let (w, rows): (Vec3, Vec<&PickRow>) = match (own, picked) {
(Some(i), _) => (pt, local.iter().filter(|r| r.body == i).collect()),
(None, Some(w)) if !world_rows().is_empty() => (w, world_rows().iter().collect()),
_ => (pt, local.iter().collect()),
};
let q_of = |r: &PickRow| {
let q = solidcraft_doc::apply_point(&r.inverse, w);
(q.x.is_finite() && q.y.is_finite() && q.z.is_finite()).then_some(q)
};
// Only bodies whose box (grown by the reach) holds the pick: an edge lies in its body's box.
let culled: Vec<(usize, Vec3)> = rows
.iter()
.enumerate()
.filter_map(|(k, r)| {
let b = st.bodies.get(r.body)?;
let q = q_of(r)?;
b.outline_bounds().inflated(edge_reach(b.body.size())).contains(q).then_some((k, q))
})
.collect();
for (k, _) in &culled {
if let Some(r) = rows.get(*k) {
shape_of(r.body, &mut shapes);
}
}
let mut hits: Vec<RowHit> = Vec::new();
for (k, q) in culled {
let Some(r) = rows.get(k) else { continue };
let (Some((size, edges, seams)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
let chord = (size * 2e-3).max(1e-3);
let mut near = Vec::new();
let mut candidates = Vec::new();
for (i, points) in edges.iter().enumerate() {
let d = polyline_dist(points, q);
if d <= edge_reach(*size) {
let seam = seams.get(i).copied().unwrap_or(false);
near.push((d, seam, leaving(points, q)));
candidates.push((i, d, seam));
}
}
if near.is_empty() {
continue;
}
// Seams count only where nothing else is near.
let edge_like = candidates.iter().any(|(_, _, seam)| !seam);
let candidates: Vec<(usize, f64)> = candidates.into_iter().filter(|(_, _, seam)| !edge_like || !seam).map(|(i, d, _)| (i, d)).collect();
let polyline = candidates.iter().map(|(_, d)| *d).fold(f64::INFINITY, f64::min);
let (d, edge) = candidates
.iter()
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
.filter_map(|b| {
let size = b.body.size();
let tol = (size * 2e-3).max(1e-3);
// The display mesh flags seams (a smooth surface meeting itself, as down the side
// of a cylinder), which nobody means by a pick where they end.
match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
// An edge where the faces meet tangentially (the spans of a spline wall
// meeting) can't be blended either: nobody means it by a pick.
Ok(m) => Some((
size,
m.edges,
m.seams
.iter()
.zip(&m.smooth_edges)
.map(|(s, t)| *s || *t)
.chain(m.seams.iter().skip(m.smooth_edges.len()).copied())
.collect(),
)),
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
}
})
.collect()
});
.filter(|(_, d)| *d <= polyline + 2.0 * chord)
.map(|(i, d)| (edge_foot(&b.body, *i, q).map_or(*d, |(_, foot)| solidcraft_doc::apply_point(&r.frame, foot).dist(w)), *i))
.fold((f64::INFINITY, None), |best, (d, i)| if d < best.0 { (d, Some(i)) } else { best });
hits.push(RowHit { row: k, q, d, edge, edge_like, near });
}
if hits.is_empty() {
if rows.is_empty() {
out.push(pt);
continue;
}
// How far the nearest edge is, on every body the pick was looked up on.
let mut nearest = f64::INFINITY;
for r in &rows {
let Some(q) = q_of(r) else { continue };
shape_of(r.body, &mut shapes);
let Some((_, edges, _)) = shapes.get(&r.body) else { continue };
nearest = edges.iter().map(|points| polyline_dist(points, q)).fold(nearest, f64::min);
}
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", shown.x, shown.y, shown.z)));
}
// A body with only a seam there has no edge to blend, where another body has one.
if hits.iter().any(|h| h.edge_like) {
hits.retain(|h| h.edge_like);
}
// Bodies with an edge as near as the nearest one's: the pick is on both. A pick off its
// curve by e widens the window to about 2e. The same body placed twice, at the same
// edge or point of its frame, is one edge.
let d_min = hits.iter().map(|h| h.d).fold(f64::INFINITY, f64::min);
let mut groups: Vec<&RowHit> = Vec::new();
for h in hits.iter().filter(|h| h.d <= 2.0 * d_min + 1e-3) {
let Some(r) = rows.get(h.row) else { continue };
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _)| *size);
let same = |g: &&RowHit| {
rows.get(g.row).is_some_and(|o| o.body == r.body) && ((g.edge.is_some() && g.edge == h.edge) || g.q.dist(h.q) <= 1e-6 * size.max(1.0))
};
if !groups.iter().any(same) {
groups.push(h);
}
}
if groups.len() > 1 {
let names: Vec<String> = groups.iter().filter_map(|g| rows.get(g.row).map(|r| r.name.clone())).collect();
let hidden: String = names.iter().filter(|n| !s.body_visible(n)).map(|n| format!(" ({n} is hidden)")).collect();
return Err(bad(
cmd,
format!(
"[{}, {}, {}] lies on edges of {} bodies, {}: name the one to {verb} with `body`{hidden}",
shown.x,
shown.y,
shown.z,
names.len(),
and_list(&names, NAMED_BODIES)
),
));
}
let Some(hit) = hits.iter().filter(|h| h.d <= d_min).chain(hits.iter()).next() else { continue };
let Some(row) = rows.get(hit.row) else { continue };
// A point where several edges meet (a corner) is ambiguous: the kernel would round one of
// them, picked silently (#5173). Edges that carry straight on through the point (the two
// halves of a circle) are one edge to whoever picked it, and seams and tangent joins don't
// count. Directions
// come from the polylines' end chords, which lean off a curve's tangent: within about 45°
// of opposite counts as carrying on (edges at a real corner are far from opposite).
let mut nearest = f64::INFINITY;
let mut near = Vec::new();
for (size, edges, seams) in bodies.iter() {
for (i, points) in edges.iter().enumerate() {
let d = points.windows(2).filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
if d <= size * 0.05 + 1e-3 {
near.push((d, seams.get(i).copied().unwrap_or(false), leaving(points, pt)));
}
}
}
let matches = near.len();
let mut meeting: Vec<Option<Vec3>> = near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
// count. Directions come from the polylines' end chords, which lean off a curve's
// tangent: within about 45° of opposite counts as carrying on (edges at a real corner are
// far from opposite).
let nearest = hit.near.iter().map(|(d, _, _)| *d).fold(f64::INFINITY, f64::min);
let mut meeting: Vec<Option<Vec3>> = hit.near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
let mut chains = 0usize;
while let Some(dir) = meeting.pop() {
chains += 1;
@@ -1639,16 +1832,107 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
if chains > 1 {
return Err(bad(
cmd,
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", pt.x, pt.y, pt.z),
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", shown.x, shown.y, shown.z),
));
}
if matches == 0 && !bodies.is_empty() {
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
}
out.push(pt);
found.push((row.body, row.name.clone(), hit.q, shown, hit.d));
out.push(hit.q);
}
Ok(out)
if given.is_some() {
return Ok(EdgePicks { points: out, body: given.map(str::to_string) });
}
let mut bodies: Vec<(usize, String)> = Vec::new();
for (i, name, ..) in &found {
if !bodies.iter().any(|(b, _)| b == i) {
bodies.push((*i, name.clone()));
}
}
let body = match bodies.as_slice() {
// One body of several: the feature keeps it, so the model never looks the edges up on
// another body that has one in the same place in its own frame.
[(i, _)] => st.bodies.get(*i).filter(|_| local.len() > 1).map(|b| b.name.clone()),
[] => None,
_ => {
// Several: each edge is looked up on its own when the model is built, on whichever
// body has an edge there in its own frame, so another body's edge there is refused.
for (i, _, q, shown, d) in &found {
for other in local.iter().filter(|r| r.body != *i) {
let Some(b) = st.bodies.get(other.body) else { continue };
let reach = edge_reach(b.body.size());
if !b.outline_bounds().inflated(reach).contains(*q) {
continue;
}
shape_of(other.body, &mut shapes);
let Some((size, edges, seams)) = shapes.get(&other.body) else { continue };
// The exact curve is no nearer than the polyline less its chord tolerance.
let chord = (size * 2e-3).max(1e-3);
let ghost = edges.iter().enumerate().filter(|(k, _)| !seams.get(*k).copied().unwrap_or(false)).any(|(k, points)| {
let poly = polyline_dist(points, *q);
poly <= reach && poly - chord <= 2.0 * d + 1e-3 && edge_foot(&b.body, k, *q).map_or(poly, |(d, _)| d) <= 2.0 * d + 1e-3
});
if ghost {
let names: Vec<String> = bodies.iter().map(|(_, n)| n.clone()).collect();
return Err(bad(
cmd,
format!(
"the picks lie on {} bodies, {}, and at [{}, {}, {}] {} has an edge in the same place in its own component: {verb} each body in a command of its own, naming it with `body`",
names.len(),
and_list(&names, NAMED_BODIES),
shown.x,
shown.y,
shown.z,
b.name
),
));
}
}
}
None
}
};
Ok(EdgePicks { points: out, body })
}
/// "A", "A and B", "A, B and C", and past `most` names "A, B and 3 more".
fn and_list(names: &[String], most: usize) -> String {
let mut shown: Vec<String> = names.iter().take(most).cloned().collect();
if names.len() > most {
shown.push(format!("{} more", names.len() - most));
}
match shown.split_last() {
Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
Some((last, _)) => last.clone(),
None => String::new(),
}
}
/// The distance from `q` to edge `index` of `body` on its exact curve, and the point there:
/// samples, then a golden-section search between the best one's neighbours.
fn edge_foot(body: &solidcraft_kernel::Body, index: usize, q: Vec3) -> Option<(f64, Vec3)> {
const SAMPLES: usize = 32;
const STEPS: usize = 40;
let e = body.record().edges.get(index)?;
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
let [t0, t1] = e.interval;
if !(t0.is_finite() && t1.is_finite()) {
return None;
}
let at = |t: f64| c.point(t).map(|p| p.dist(q)).filter(|d| d.is_finite());
let step = (t1 - t0) / SAMPLES as f64;
let (k, _) = (0..=SAMPLES).filter_map(|k| Some((k, at(t0 + step * k as f64)?))).min_by(|a, b| a.1.total_cmp(&b.1))?;
let (mut lo, mut hi) = (t0 + step * k.saturating_sub(1) as f64, t0 + step * (k + 1).min(SAMPLES) as f64);
let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
for _ in 0..STEPS {
let (a, b) = (hi - ratio * (hi - lo), lo + ratio * (hi - lo));
if at(a).unwrap_or(f64::INFINITY) <= at(b).unwrap_or(f64::INFINITY) {
hi = b;
} else {
lo = a;
}
}
let foot = c.point((lo + hi) / 2.0)?;
let d = foot.dist(q);
d.is_finite().then_some((d, foot))
}
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
@@ -1687,7 +1971,11 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.press_pull";
let distance = req_expr(cmd, p, "distance")?;
if p.get("edges").is_some() {
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
let mut q = json!({"edges": p.get("edges"), "radius": distance});
if let Some(body) = p.get("body").filter(|b| b.is_string()) {
q["body"] = body.clone();
}
return fillet(s, &q);
}
let face = p.get("face").ok_or_else(|| bad(cmd, "needs `face` or `edges`"))?;
let d = s.doc.eval(&distance, Kind::Length).map_err(|e| bad(cmd, format!("distance: {e}")))?;
@@ -1737,7 +2025,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.fillet";
let edges = edge_points(s, p, cmd)?;
let picks = edge_points(s, p, cmd)?;
let radius = req_expr(cmd, p, "radius")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
@@ -1840,9 +2128,9 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
s,
p,
FeatureKind::Fillet {
edges: edges.into_iter().map(Position).collect(),
edges: picks.points.into_iter().map(Position).collect(),
radius,
body: str_(p, "body").map(BodyName::from),
body: picks.body.map(BodyName::from),
style,
fit,
corner,
@@ -1866,7 +2154,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.chamfer";
let edges = edge_points(s, p, cmd)?;
let picks = edge_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let (distance2, angle) = (expr(p, "distance2"), expr(p, "angle"));
@@ -1884,12 +2172,12 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
s,
p,
FeatureKind::Chamfer {
edges: edges.into_iter().map(Position).collect(),
edges: picks.points.into_iter().map(Position).collect(),
distance,
distance2,
angle,
flip,
body: str_(p, "body").map(BodyName::from),
body: picks.body.map(BodyName::from),
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
},
)
+9 -4
View File
@@ -1393,13 +1393,18 @@ mod tests {
assert_eq!(r["bytes"].as_u64(), Some(bytes.len() as u64), "{cmd}");
assert!(bytes.starts_with(signature), "{cmd}");
}
// The triangles `file.export` writes (the 80-byte header holds the file's name): a box is
// 12 triangles, 84 + 50 bytes each.
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
assert_eq!(std::fs::metadata(at("box.stl")).unwrap().len(), 84 + 50 * 12);
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 2);
let stl = std::fs::read(at("box.stl")).unwrap();
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
assert_eq!(stl.len(), 84 + 50 * triangles);
let obj = std::fs::read_to_string(at("Obj.txt")).unwrap();
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12, "{obj}");
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), triangles, "{obj}");
let r = s.execute("file.export_urdf", &json!({"path": at("robot.urdf")})).unwrap();
assert_eq!(r["format"], "Urdf");
assert!(std::fs::read_to_string(at("robot.urdf")).unwrap().contains("<robot"));
+60 -25
View File
@@ -6,7 +6,7 @@
use solidcraft_geom::prelude::*;
use std::collections::BTreeMap;
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::{Loop2, Seg2, Vec2};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::frame::{self, Anchor, MemberProfile, Profile, ProfileSpec};
@@ -17,12 +17,23 @@ use super::features::{add_feature, check_expr, sketch_id};
use super::sketch::edit;
use super::{CommandSpec, in_sketch, not_in_sketch};
use crate::params::{bad, bool_, expr, num, str_, string_list, vec2};
use crate::schema::features::FeatureResult;
use crate::schema::frame::{
CreatedIds, FrameMemberParams, ProfileInsertParams, ProfileInsertResult, ProfileKindInfo, ProfileListParams, ProfileListResult, ProfileOut,
ProfilePropertiesParams, ProfilePropertiesResult, ProfileSection,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
#[cfg(test)]
#[path = "frame_tests.rs"]
mod tests;
const MEMBER_CONTRACT: TypedContract<FrameMemberParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ProfileListParams> = TypedContract::new();
const PROPERTIES_CONTRACT: TypedContract<ProfilePropertiesParams> = TypedContract::new();
const INSERT_CONTRACT: TypedContract<ProfileInsertParams> = TypedContract::new();
/// The profile parameter, as every frame command documents it.
macro_rules! profile_doc {
() => {
@@ -31,20 +42,40 @@ macro_rules! profile_doc {
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("frame.member", "Frame Member", member).at("WELDMENTS", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!(
CommandSpec::new("frame.member", "Frame Member", crate::checked!("frame.member", member))
.at("WELDMENTS", "CREATE")
.icon("frame_member")
.enabled(not_in_sketch)
.contract(&MEMBER_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(concat!(
"sketch: the frame sketch (id or name; 2D or 3D); lines?: [curve ids] (one member each; default: every straight line drawn in the sketch: not construction, not projected or otherwise linked, not of no length); ",
profile_doc!(),
" | profile_sketch: a sketch whose profile is the section, in its own x and y around its origin, with profiles?: picks as solid.extrude takes them (default: its material, nested loops alternating, so two circles make a tube); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point that sits on each line, default origin); angle?: angle expr (the profile turned about the line; default 0: its y as near world up as the line allows, a vertical member's x along world X); mirror?: bool (the profile mirrored across its y axis); name?; body_name? (what the members are named after, then numbered: `Rail`, `Rail (2)`…; default the profile's description, `Rect Tube 40x40x3`, or the profile sketch's name); names?: {line id: name} (names to keep, as Edit Feature keeps them). One body per line, from its start to its end; each member keeps its name when other lines are added or deleted. Returns the feature and the bodies it made"
)),
CommandSpec::new("frame.profile.list", "Profile Library", list).icon("library")
CommandSpec::new("frame.profile.list", "Profile Library", crate::checked!("frame.profile.list", list))
.icon("library")
.noundo()
.doc_only()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ProfileListResult>)
.params("Returns kinds: [{kind, label, required, optional, example}]: the built-in profiles, each SolidCraft's own parametric definition"),
CommandSpec::new("frame.profile.properties", "Profile Properties", properties).icon("measure").noundo().doc_only().params(concat!(
CommandSpec::new("frame.profile.properties", "Profile Properties", crate::checked!("frame.profile.properties", properties))
.icon("measure")
.noundo()
.doc_only()
.contract(&PROPERTIES_CONTRACT)
.output(output_schema_for::<ProfilePropertiesResult>)
.params(concat!(
profile_doc!(),
"; density?: kg/m³ (adds mass_per_m, kg/m). Returns profile (every default filled in), area (mm²), centroid [x,y] (mm, from the bounding box centre), ixx, iyy, ixy (mm⁴ about the centroid), width, height"
)),
CommandSpec::new("frame.profile.insert", "Insert Profile", insert).icon("import").enabled(in_sketch).params(concat!(
CommandSpec::new("frame.profile.insert", "Insert Profile", crate::checked!("frame.profile.insert", insert))
.icon("import")
.enabled(in_sketch)
.contract(&INSERT_CONTRACT)
.output(output_schema_for::<ProfileInsertResult>)
.params(concat!(
profile_doc!(),
"; at?: [x,y] (sketch mm, default the origin); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point placed on `at`: its origin, the default and a built-in profile's centre, its bounding box's centre, its centroid, or a corner or edge middle of its bounding box); angle?: radians, counter-clockwise; fixed?: bool (default true: the curves are locked, so solving the sketch keeps the shape); sketch?: id|name (default: the sketch being edited). Returns the section as frame.profile.properties does",
created_doc!()
@@ -73,29 +104,32 @@ fn profile(s: &Session, cmd: &str, p: &Value) -> Result<Profile> {
resolve(s, cmd, &profile_spec(cmd, p)?)
}
/// A frame command's reply, built from its schema's type so the two can't drift apart.
fn frame_reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
}
fn list(_: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"kinds": frame::profile_kinds()}))
let kinds = frame::profile_kinds().iter().map(ProfileKindInfo::from).collect();
frame_reply("frame.profile.list", ProfileListResult { kinds, param_warnings: Vec::new() })
}
/// The profile's section as the commands report it.
fn section_json(cmd: &str, profile: &Profile, density: Option<f64>) -> Result<Value> {
fn section(cmd: &str, profile: &Profile, density: Option<f64>) -> Result<ProfileSection> {
let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?;
let s = frame::section(&region);
let mut out = json!({
"profile": profile.filled(),
"area": s.area,
"centroid": s.centroid,
"ixx": s.ixx,
"iyy": s.iyy,
"ixy": s.ixy,
"width": s.width,
"height": s.height,
});
if let (Some(rho), Some(o)) = (density, out.as_object_mut()) {
Ok(ProfileSection {
profile: ProfileOut(profile.filled()),
area: s.area,
centroid: s.centroid,
ixx: s.ixx,
iyy: s.iyy,
ixy: s.ixy,
width: s.width,
height: s.height,
// mm² × 1e-6 m²/mm² × kg/m³ = kg/m.
o.insert("mass_per_m".into(), json!(s.area * 1e-6 * rho));
}
Ok(out)
mass_per_m: density.map(|rho| s.area * 1e-6 * rho),
})
}
fn properties(s: &mut Session, p: &Value) -> Result<Value> {
@@ -109,7 +143,7 @@ fn properties(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, "`density` must be a positive number of kg/m³ (at most 1e6)"))?,
),
};
section_json(cmd, &profile, density)
frame_reply(cmd, ProfilePropertiesResult { section: section(cmd, &profile, density)?, param_warnings: Vec::new() })
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
@@ -135,15 +169,16 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
};
let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?;
let placed = frame::placed(&region, frame::anchor_point(std::slice::from_ref(&region), anchor), at, angle);
let section = section_json(cmd, &profile, None)?;
let section = section(cmd, &profile, None)?;
let (created, info) = edit(s, p, cmd, false, |sk, _| {
let before = sk.clone();
for l in std::iter::once(&placed.outer).chain(&placed.holes) {
add_loop(sk, l, fixed, cmd)?;
}
Ok(super::sketch_created(Some(&before), sk))
serde_json::from_value::<CreatedIds>(super::sketch_created(Some(&before), sk)).map_err(|e| bad(cmd, e.to_string()))
})?;
Ok(json!({"created": created, "section": section, "sketch": info}))
let sketch = serde_json::from_value(info).map_err(|e| bad(cmd, e.to_string()))?;
frame_reply(cmd, ProfileInsertResult { created, section, sketch, param_warnings: Vec::new() })
}
/// Draw the closed loop `l` (lines and arcs) into the sketch, each segment sharing its end
+328 -139
View File
@@ -12,13 +12,25 @@ use solidcraft_sketch::CurveKind;
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list};
use crate::schema::inspect::{
BodyListParams, FaceCylinder, ModelEdge, ModelEdges, ModelFace, ModelFaces, ModelThread, ModelThreads, ModelThreadsParams,
BodyFailure, BodyFeaturesParams, BodyFigures, BodyListParams, BodyMeasurement, BodyName, BodyNames, BodyOutcome, BoundingBox, CommandEntry,
CommandsParams, CurveShape, DocumentBody, DocumentBodyDetail, DocumentInspectParams, DocumentInspectResult, DocumentParam, DocumentSketch,
FaceCylinder, KernelCounts, ListedMaterial, MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams,
MeasureResult, MeasureTotal, Measured, MeasuredBodies, MeasuredBody, ModelBodyFeatures, ModelEdge, ModelEdges, ModelFace, ModelFaces,
ModelThread, ModelThreads, ModelThreadsParams, SketchConstraint, SketchCurve, SketchInspectParams, SketchInspected, SketchPoint, SketchProfile,
SketchWire, TimelineEntry, WireArcInfo,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const BODY_LIST_CONTRACT: TypedContract<BodyListParams> = TypedContract::new();
const THREADS_CONTRACT: TypedContract<ModelThreadsParams> = TypedContract::new();
const BODY_FEATURES_CONTRACT: TypedContract<BodyFeaturesParams> = TypedContract::new();
const MEASURE_CONTRACT: TypedContract<MeasureParams> = TypedContract::new();
const DOCUMENT_INSPECT_CONTRACT: TypedContract<DocumentInspectParams> = TypedContract::new();
const SKETCH_INSPECT_CONTRACT: TypedContract<SketchInspectParams> = TypedContract::new();
const MATERIAL_ASSIGN_CONTRACT: TypedContract<MaterialAssignParams> = TypedContract::new();
const MATERIAL_LIST_CONTRACT: TypedContract<MaterialListParams> = TypedContract::new();
const COMMANDS_CONTRACT: TypedContract<CommandsParams> = TypedContract::new();
#[path = "appearance_chain.rs"]
mod appearance_chain;
@@ -26,23 +38,39 @@ mod appearance_chain;
#[path = "appearance_gestures.rs"]
pub(super) mod appearance_gestures;
#[path = "body_features.rs"]
mod body_features;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
.at("SOLID", "INSPECT").icon("colored_components").noundo()
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
CommandSpec::new("inspect.measure", "Measure", measure)
CommandSpec::new("inspect.measure", "Measure", crate::checked!("inspect.measure", measure))
.at("SOLID", "INSPECT")
.icon("measure")
.key("I")
.noundo()
.contract(&MEASURE_CONTRACT)
.output(output_schema_for::<MeasureResult>)
.params("bodies?: [names] (default all); names_only?: bool (each world body's name and its definition's, nothing measured) → closed per body; volume_mm3, center_of_mass, density_g_cm3 and mass_g only for closed, consistently oriented bodies; area, bbox and counts for all; total.open_bodies_skipped counts bodies excluded from volume/mass totals; or items: [1-2 selections] (face/edge: type, body, point; index optional) → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
CommandSpec::new("document.inspect", "Inspect Design", crate::checked!("document.inspect", inspect))
.icon("inspect")
.noundo()
.contract(&DOCUMENT_INSPECT_CONTRACT)
.output(output_schema_for::<DocumentInspectResult>)
.params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
CommandSpec::new("document.revision", "Design Revision", revision).icon("inspect").noundo().params(
"→ document_id (this open design's id: kept while it stays open, another for each design started or opened), \
revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \
never on a selection or a view; from one counter for the whole run, so it never repeats). Read-only",
),
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).at("SKETCH", "INSPECT").icon("inspect").noundo().params("sketch?: id|name (default active)"),
CommandSpec::new("sketch.inspect", "Inspect Sketch", crate::checked!("sketch.inspect", sketch_inspect))
.at("SKETCH", "INSPECT")
.icon("inspect")
.noundo()
.contract(&SKETCH_INSPECT_CONTRACT)
.output(output_schema_for::<SketchInspected>)
.params("sketch?: id|name (default active)"),
CommandSpec::new("model.edges", "List Edges", crate::checked!("model.edges", model_edges))
.icon("inspect")
.noundo()
@@ -65,9 +93,20 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&THREADS_CONTRACT)
.output(output_schema_for::<ModelThreads>),
CommandSpec::new("material.assign", "Physical Material", physical_material)
CommandSpec::new("model.body_features", "List Body Features", crate::checked!("model.body_features", body_features::model_body_features))
.icon("inspect")
.noundo()
.contract(&BODY_FEATURES_CONTRACT)
.output(output_schema_for::<ModelBodyFeatures>)
.params(
"body?: name (a placed copy's name means the body it shows; default every body) → bodies: [{body, features: [{id, name}]}], \
in timeline order the feature that created each body, every later one that changed it, and those of bodies consumed into it. Read-only",
),
CommandSpec::new("material.assign", "Physical Material", crate::checked!("material.assign", physical_material))
.at("SOLID", "MODIFY")
.icon("material")
.contract(&MATERIAL_ASSIGN_CONTRACT)
.output(output_schema_for::<MaterialAssignResult>)
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
CommandSpec::new("appearance.assign", "Appearance", crate::checked!("appearance.assign", appearance))
.at("SOLID", "MODIFY")
@@ -104,11 +143,18 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&LIST_CONTRACT)
.output(crate::schema::output_schema_for::<AppearanceListResult>)
.params("body?: name (or a placed copy's, `KB (K:2)`: the body it shows) → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).icon("list")
CommandSpec::new("material.list", "List Materials", crate::checked!("material.list", material_list))
.icon("list")
.noundo()
.doc_only()
.contract(&MATERIAL_LIST_CONTRACT)
.output(output_schema_for::<MaterialListResult>)
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
CommandSpec::new("engine.commands", "List Commands", crate::checked!("engine.commands", commands))
.icon("list")
.noundo()
.contract(&COMMANDS_CONTRACT)
.output(output_schema_for::<Vec<CommandEntry>>),
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", describe))
.icon("list")
.noundo()
@@ -128,7 +174,7 @@ pub(crate) fn encloses_volume(body: &solidcraft_kernel::Body) -> bool {
}
pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value {
measure_sheet_json(b, None)
serde_json::to_value(measure_sheet(b, None)).unwrap_or_default()
}
pub(super) fn measure_body(
@@ -150,27 +196,53 @@ pub(super) fn measure_body(
Ok(measured)
}
fn measure_sheet_json(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> Value {
fn measure_sheet(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> BodyMeasurement {
let name = b.name.clone();
match measure_body(b, definitions) {
Ok(m) => {
let closed = encloses_volume(&b.body);
json!({
"name": b.name,
"closed": closed,
"volume_mm3": closed.then_some(m.volume),
"area_mm2": m.area,
"center_of_mass": closed.then_some(m.centroid),
"bbox": {"min": m.bbox.min, "max": m.bbox.max},
"faces": m.merged.faces,
"edges": m.merged.edges,
"vertices": m.merged.vertices,
"face_types": m.merged.face_types,
"kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells},
"measured_by": m.measured_by,
"notes": m.notes,
})
let outcome = BodyOutcome::Measured(BodyFigures {
closed,
volume_mm3: closed.then_some(m.volume),
area_mm2: m.area,
center_of_mass: closed.then(|| m.centroid.into()),
bbox: BoundingBox { min: m.bbox.min.into(), max: m.bbox.max.into() },
faces: count(m.merged.faces),
edges: count(m.merged.edges),
vertices: count(m.merged.vertices),
face_types: m.merged.face_types,
kernel: KernelCounts { faces: m.faces, edges: m.edges, vertices: m.vertices, shells: m.shells },
measured_by: m.measured_by.to_string(),
notes: m.notes,
});
BodyMeasurement { name, outcome }
}
Err(e) => measure_failed(name, e.to_string()),
}
}
fn measure_failed(name: String, error: String) -> BodyMeasurement {
BodyMeasurement { name, outcome: BodyOutcome::Failed(BodyFailure { error, faces: None, edges: None, vertices: None }) }
}
/// A topology count as the replies give it.
fn count(n: usize) -> i64 {
i64::try_from(n).unwrap_or(0)
}
/// A reply as JSON: the commands build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn typed_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
impl BodyMeasurement {
/// The volume, when the body has one that is a number.
fn volume(&self) -> Option<f64> {
match &self.outcome {
BodyOutcome::Measured(f) => f.volume_mm3.filter(|v| v.is_finite()),
BodyOutcome::Failed(_) => None,
}
Err(e) => json!({"name": b.name, "error": e.to_string()}),
}
}
@@ -187,138 +259,217 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "unknown body name"));
}
if bool_(p, "names_only").unwrap_or(false) {
let bodies: Vec<_> = selected.iter().map(|b| json!({"name": b.name, "definition": b.definition_name()})).collect();
return Ok(json!({"body_count": bodies.len(), "bodies": bodies}));
let bodies: Vec<_> = selected.iter().map(|b| BodyName { name: b.name.clone(), definition: b.definition_name().to_string() }).collect();
let measured = Measured::Names(BodyNames { body_count: bodies.len(), bodies });
return typed_reply(cmd, MeasureResult { measured, param_warnings: Vec::new() });
}
// Each body is measured on its own: on all cores, in body order.
let mut bodies: Vec<Value> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet_json(b, Some(&definitions)))
let measurements: Vec<BodyMeasurement> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet(b, Some(&definitions)))
.into_iter()
.zip(&selected)
.map(|(m, b)| m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})))
.map(|(m, b)| m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into())))
.collect();
// Mass from each body's material.
for (b, body) in bodies.iter_mut().zip(selected) {
let mut bodies: Vec<MeasuredBody> = Vec::with_capacity(measurements.len());
for (measurement, body) in measurements.into_iter().zip(selected) {
let source = body.definition_name();
// Every body's material, measured or not: naming it takes no measuring.
b["material"] = json!(s.doc.body_material(source));
if let Some(v) = b["volume_mm3"].as_f64() {
let mut b = MeasuredBody { measurement, material: s.doc.body_material(source), density_g_cm3: None, mass_g: None, sheet_metal: None };
if let Some(v) = b.measurement.volume() {
let rho = s.doc.density(source);
b["density_g_cm3"] = json!(rho);
b["mass_g"] = json!(v * rho / 1000.0);
b.density_g_cm3 = Some(rho);
b.mass_g = Some(v * rho / 1000.0);
}
// Only Measure exposes sheet boundary counts; document.inspect keeps kernel counts.
if let Some(sheet) = definitions.sheets.iter().find(|sheet| sheet.body == source) {
let (df, de, dv) = sheet.round_hole_correction();
for (key, delta) in [("faces", df), ("edges", de), ("vertices", dv)] {
b[key] = json!(b["kernel"][key].as_i64().unwrap_or(0) + delta);
match &mut b.measurement.outcome {
BodyOutcome::Measured(f) => {
f.faces = count(f.kernel.faces) + df;
f.edges = count(f.kernel.edges) + de;
f.vertices = count(f.kernel.vertices) + dv;
}
BodyOutcome::Failed(x) => (x.faces, x.edges, x.vertices) = (Some(df), Some(de), Some(dv)),
}
b["sheet_metal"] = json!(true);
b.sheet_metal = Some(true);
}
bodies.push(b);
}
fn figures(b: &MeasuredBody) -> Option<&BodyFigures> {
match &b.measurement.outcome {
BodyOutcome::Measured(f) => Some(f),
BodyOutcome::Failed(_) => None,
}
}
let skipped = bodies.iter().filter(|b| b["closed"] == false).count();
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum();
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
let tm: f64 = bodies.iter().filter_map(|b| b["mass_g"].as_f64()).sum();
Ok(
json!({"body_count": bodies.len(), "bodies": bodies, "total": {"open_bodies_skipped": skipped, "volume_mm3": tv, "area_mm2": ta, "mass_g": tm, "faces": tf, "edges": te, "vertices": tvx}}),
let counted = |n: Option<i64>| n.and_then(|n| u64::try_from(n).ok());
let faces = |b: &MeasuredBody| match &b.measurement.outcome {
BodyOutcome::Measured(f) => (counted(Some(f.faces)), counted(Some(f.edges)), counted(Some(f.vertices))),
BodyOutcome::Failed(x) => (counted(x.faces), counted(x.edges), counted(x.vertices)),
};
let total = MeasureTotal {
open_bodies_skipped: bodies.iter().filter(|b| figures(b).is_some_and(|f| !f.closed)).count(),
volume_mm3: bodies.iter().filter_map(|b| b.measurement.volume()).sum(),
area_mm2: bodies.iter().filter_map(|b| figures(b)).map(|f| f.area_mm2).filter(|a| a.is_finite()).sum(),
mass_g: bodies.iter().filter_map(|b| b.mass_g).filter(|m| m.is_finite()).sum(),
faces: bodies.iter().filter_map(|b| faces(b).0).sum(),
edges: bodies.iter().filter_map(|b| faces(b).1).sum(),
vertices: bodies.iter().filter_map(|b| faces(b).2).sum(),
};
typed_reply(
cmd,
MeasureResult { measured: Measured::Bodies(MeasuredBodies { body_count: bodies.len(), bodies, total }), param_warnings: Vec::new() },
)
}
/// A 2D position as the replies give it.
fn at2(p: solidcore_record::Vec2) -> [f64; 2] {
[p.x, p.y]
}
/// Sketch `id`, placed where the placed model `world` has it (its component's placement).
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<Value> {
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<SketchInspected> {
let state = s.model.state();
let ss = state.sketch(id)?;
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
let sk = &ss.sketch;
let curves: Vec<Value> = sk
let curves: Vec<SketchCurve> = sk
.curves
.iter()
.enumerate()
.map(|(i, c)| {
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
let mut v = match c.kind {
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => {
let mut v = json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det});
// A control spline with its own knots (a projected free-form edge) says them.
if let (Some(n), Some(o)) = (nurbs, v.as_object_mut()) {
o.insert("knots".into(), json!(n.knots));
if !n.weights.is_empty() {
o.insert("weights".into(), json!(n.weights));
}
}
v
let at = |k: usize| sk.point(k).map(at2);
let shape = match c.kind {
CurveKind::Line { a, b } => CurveShape::Line { start: pid(a), end: pid(b), start_at: at(a), end_at: at(b) },
CurveKind::Circle { c: cc, r } => CurveShape::Circle { center: pid(cc), center_at: at(cc), radius: r },
CurveKind::Arc { c: cc, a, b } => CurveShape::Arc {
center: pid(cc),
start: pid(a),
end: pid(b),
center_at: at(cc),
start_at: at(a),
end_at: at(b),
radius: sk.radius(i),
},
CurveKind::Ellipse { c: cc, m, r } => {
CurveShape::Ellipse { center: pid(cc), major: pid(m), center_at: at(cc), major_at: at(m), minor_radius: r }
}
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => CurveShape::Spline {
points: pts.iter().map(|q| pid(*q)).collect(),
points_at: pts.iter().map(|q| at(*q)).collect(),
control,
degree,
periodic,
// A control spline with its own knots (a projected free-form edge) says them.
knots: nurbs.as_ref().map(|n| n.knots.clone()),
weights: nurbs.as_ref().filter(|n| !n.weights.is_empty()).map(|n| n.weights.clone()),
},
CurveKind::Conic { a, b, apex, rho } => {
CurveShape::Conic { start: pid(a), end: pid(b), apex: pid(apex), start_at: at(a), end_at: at(b), apex_at: at(apex), rho }
}
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
};
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
o.insert("link".into(), json!(l));
SketchCurve {
id: c.id.clone(),
shape,
construction: c.construction,
fixed: sk.curve_locked(i),
fully_constrained: det,
link: c.link.clone(),
}
v
})
.collect();
let locked = sk.locked_points();
let points: Vec<Value> = sk
let points: Vec<SketchPoint> = sk
.points
.iter()
.enumerate()
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.map(|(i, p)| SketchPoint {
id: p.id.clone(),
at: at2(p.pos),
world: plane.to_world(p.pos).into(),
fixed: locked.get(i).copied().unwrap_or(false),
link: p.link.clone(),
fully_constrained: ss.report.point_determined.get(i).copied().unwrap_or(false),
})
.collect();
let constraints: Vec<Value> = sk
let constraints: Vec<SketchConstraint> = sk
.constraints
.iter()
.map(|c| {
let mut v = serde_json::to_value(&c.kind).unwrap_or_default();
if let Some(o) = v.as_object_mut() {
o.insert("id".into(), json!(c.id));
o.insert("name".into(), json!(c.kind.name()));
if let Some(p) = &c.param {
o.insert("param".into(), json!(p));
o.insert("expression".into(), json!(s.doc.param(p).map(|x| x.expr.clone())));
}
let mut kind = match serde_json::to_value(&c.kind) {
Ok(Value::Object(kind)) => kind,
_ => Default::default(),
};
// The constraint's own keys win over any of the kind's.
for key in ["id", "name", "param", "expression"] {
kind.remove(key);
}
SketchConstraint {
id: c.id.clone(),
name: c.kind.name().to_string(),
param: c.param.clone(),
expression: c.param.as_ref().map(|p| s.doc.param(p).map(|x| x.expr.clone())),
kind,
}
v
})
.collect();
let profiles: Vec<Value> = ss
let profiles: Vec<SketchProfile> = ss
.profiles
.iter()
.enumerate()
// A loop records one entry per piece it walks, and a conic or spline is walked as many
// short pieces: each loop names a curve once, in loop order.
.map(|(i, p)| {
let holes: Vec<Vec<String>> = p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect();
json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": solidcraft_sketch::distinct_curves(&p.outer_curves), "holes": holes})
.map(|(i, p)| SketchProfile {
index: i,
area_mm2: p.area,
centroid_sketch: at2(p.centroid),
curves: solidcraft_sketch::distinct_curves(&p.outer_curves),
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
})
.collect();
Some(json!({
"id": id,
"name": ss.name,
"plane": ss.plane,
"status": ss.report.status,
"dof": ss.report.dof,
"fully_constrained": ss.report.fully_constrained(),
"failing": ss.report.failing,
"curves": curves,
"points": points,
"constraints": constraints,
"profiles": profiles,
"links": sk.links,
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link, "arc": w.arc.map(|a| json!({"center": a.center, "radius": a.radius, "start": a.start, "end": a.end, "sweep_deg": solidcraft_sketch::arc_sweep(&a).to_degrees()}))})).collect::<Vec<_>>(),
"visible": crate::view::sketch_shown(s, id),
}))
let wires = sk
.wires
.iter()
.map(|w| SketchWire {
id: w.id.clone(),
points: w.pts.len(),
start: w.pts.first().map(|&p| p.into()),
end: w.pts.last().map(|&p| p.into()),
link: w.link.clone(),
arc: w.arc.map(|a| WireArcInfo {
center: a.center.into(),
radius: a.radius,
start: a.start.into(),
end: a.end.into(),
sweep_deg: solidcraft_sketch::arc_sweep(&a).to_degrees(),
}),
})
.collect();
Some(SketchInspected {
id,
name: ss.name.clone(),
plane: serde_json::to_value(ss.plane).unwrap_or_default(),
status: ss.report.status.into(),
dof: ss.report.dof,
fully_constrained: ss.report.fully_constrained(),
failing: ss.report.failing.clone(),
curves,
points,
constraints,
profiles,
links: serde_json::to_value(&sk.links).unwrap_or_default(),
wires,
visible: crate::view::sketch_shown(s, id),
param_warnings: Vec::new(),
})
}
fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::sketch_store::target(s, p, "sketch.inspect")?;
sketch_json(s, &*s.world_state()?, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
let inspected = sketch_json(s, &*s.world_state()?, id)
.ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))?;
typed_reply("sketch.inspect", inspected)
}
/// Revisions of every design of this run (#6646).
@@ -377,24 +528,38 @@ fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let (vals, perr) = s.doc.param_values();
let params: Vec<Value> = s
let params: Vec<DocumentParam> = s
.doc
.params
.iter()
.map(|p| json!({"name": p.name, "expression": p.expr, "unit": p.unit, "value": vals.get(&p.name).map(|v| v.v), "model": p.model, "comment": p.comment, "error": perr.get(&p.name)}))
.map(|p| DocumentParam {
name: p.name.clone(),
expression: p.expr.clone(),
unit: p.unit.clone(),
value: vals.get(&p.name).map(|v| v.v),
model: p.model,
comment: p.comment.clone(),
error: perr.get(&p.name).cloned(),
})
.collect();
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
let timeline: Vec<Value> = s
let timeline: Vec<TimelineEntry> = s
.doc
.features
.iter()
.enumerate()
.map(|(i, f)| {
let r = s.model.result(f.id);
json!({
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
})
TimelineEntry {
id: f.id,
name: f.name.clone(),
kind: f.kind.type_name().to_string(),
suppressed: f.suppressed,
rolled_back: i >= marker,
error: r.and_then(|r| r.error.clone()),
warning: r.and_then(|r| r.warning.clone()),
ms: r.map(|r| r.ms),
}
})
.collect();
let definitions = s.model.state();
@@ -402,46 +567,69 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let with_counts = bool_(p, "sketch_counts").unwrap_or(false);
// Each body is measured on its own: on all cores, in body order (as `inspect.measure`).
let mut measured =
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet_json(b, Some(&definitions))) } else { Vec::new() }
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet(b, Some(&definitions))) } else { Vec::new() }
.into_iter();
let bodies: Vec<Value> = st
let bodies: Vec<DocumentBody> = st
.bodies
.iter()
.map(|b| {
let mut v = match measured.next() {
Some(m) => m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})),
None => json!({"name": b.name, "feature": b.feature}),
};
if let Some(o) = v.as_object_mut() {
o.insert("visible".into(), json!(s.body_visible(&b.name)));
if s.body_removed_later(&b.name) {
// Editing a sketch rolls the timeline back; this body is deleted further on.
o.insert("removed_later".into(), json!(true));
let detail = match measured.next() {
Some(m) => {
DocumentBodyDetail::Measured(Box::new(m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into()))))
}
}
v
None => DocumentBodyDetail::Listed { name: b.name.clone(), feature: b.feature },
};
// Editing a sketch rolls the timeline back; a body deleted further on is `removed_later`.
DocumentBody { detail, visible: s.body_visible(&b.name), removed_later: s.body_removed_later(&b.name).then_some(true) }
})
.collect();
let sketches: Vec<Value> = st
let sketches: Vec<DocumentSketch> = st
.sketches
.iter()
.map(|ss| {
let mut v = json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)});
let mut v = DocumentSketch {
id: ss.feature,
name: ss.name.clone(),
dof: ss.report.dof,
status: ss.report.status.into(),
curves: ss.sketch.curves.len(),
profiles: ss.profiles.len(),
visible: crate::view::sketch_shown(s, ss.feature),
plane: None,
points: None,
constraints: None,
};
// A summary that stands in for the sketch's contents (the compact MCP inspect_design).
if with_counts && let Some(o) = v.as_object_mut() {
o.insert("plane".into(), json!(ss.plane));
o.insert("points".into(), json!(ss.sketch.points.len()));
o.insert("constraints".into(), json!(ss.sketch.constraints.len()));
if with_counts {
v.plane = Some(serde_json::to_value(ss.plane).unwrap_or_default());
v.points = Some(ss.sketch.points.len());
v.constraints = Some(ss.sketch.constraints.len());
}
v
})
.collect();
Ok(json!({
"name": s.doc.name, "units": s.doc.units, "metadata": s.doc.metadata, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
"active_sketch": s.active_sketch, "marker": marker,
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
}))
typed_reply(
"document.inspect",
DocumentInspectResult {
name: s.doc.name.clone(),
units: s.doc.units.clone(),
metadata: s.doc.metadata.0.clone(),
path: s.path.clone(),
dirty: s.is_dirty(),
revision: s.revision,
active_sketch: s.active_sketch,
marker,
params,
timeline,
bodies,
sketches,
selection: serde_json::to_value(&s.selection).unwrap_or_default(),
undo: s.undo.len(),
redo: s.redo.len(),
hidden_origin: s.visibility.hidden_origin.clone(),
param_warnings: Vec::new(),
},
)
}
fn body_of<'a>(st: &'a solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<&'a solidcraft_doc::ModelBody> {
@@ -530,7 +718,8 @@ fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
}
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
let list: Vec<CommandEntry> = crate::command_specs().iter().map(|c| c.info(s).into()).collect();
typed_reply("engine.commands", list)
}
fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -554,7 +743,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
s.doc_mut().materials.insert(b.clone(), canonical.clone());
}
}
Ok(json!({"bodies": bodies, "material": canonical}))
typed_reply(cmd, MaterialAssignResult { bodies, material: canonical, param_warnings: Vec::new() })
}
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
@@ -901,18 +1090,18 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
}
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| ListedMaterial::BuiltIn { name: n.to_string(), density_g_cm3: *d });
let user = s
.doc
.user_materials
.iter()
.map(|m| {
let mut out = super::materials::material_json(m)?;
out["appearance"] = json!(s.doc.material_look(&m.name).as_ref().map(look_json));
Ok(out)
let mut out = super::materials::listed_material(m)?;
out.appearance = s.doc.material_look(&m.name).as_ref().map(LookReply::from);
Ok(ListedMaterial::Design(Box::new(out)))
})
.collect::<Result<Vec<_>>>()?;
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
typed_reply("material.list", MaterialListResult { materials: built_in.chain(user).collect(), param_warnings: Vec::new() })
}
fn colored_components(s: &mut Session, p: &Value) -> Result<Value> {
+315 -158
View File
@@ -15,20 +15,54 @@ use solidcraft_geom::Aabb3;
use super::CommandSpec;
use crate::params::{bad, bool_, num, str_, vec3};
use crate::schema::joints::EditJointResult;
use crate::schema::joints::{
AsBuiltJointParams, CamLift, CamParams, CamResult, ContactStop, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult,
DriveJointParams, DriveJointResult, EditJointResult, FloatingChain, GearParams, HeldValue, InspectJointParams, InspectJointResult,
JointAddedResult, JointDeleted, JointFrame, JointLimitsParams, JointProblem, LimitSide, LimitStop, LinkInfo, ListJointsParams, ListJointsResult,
ListedJoint, ListedOrigin, LoopStop, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, NamedValue, OccurrenceDof, RackPinionParams,
ReachLoopStop, ReachParams, ReachResult, ReachedJoint, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveReport,
SolveResult, ValueUnit,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const EDIT_CONTRACT: TypedContract<crate::schema::joints::EditJointParams> = TypedContract::new();
const CREATE_CONTRACT: TypedContract<CreateJointParams> = TypedContract::new();
const AS_BUILT_CONTRACT: TypedContract<AsBuiltJointParams> = TypedContract::new();
const DRIVE_CONTRACT: TypedContract<DriveJointParams> = TypedContract::new();
const LIMITS_CONTRACT: TypedContract<JointLimitsParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<DeleteJointParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ListJointsParams> = TypedContract::new();
const INSPECT_CONTRACT: TypedContract<InspectJointParams> = TypedContract::new();
const SOLVE_CONTRACT: TypedContract<SolveJointsParams> = TypedContract::new();
const ORIGIN_CONTRACT: TypedContract<MakeJointOriginParams> = TypedContract::new();
const RIGID_GROUP_CONTRACT: TypedContract<RigidGroupParams> = TypedContract::new();
const REACH_CONTRACT: TypedContract<ReachParams> = TypedContract::new();
const MOTION_LINK_CONTRACT: TypedContract<MotionLinkParams> = TypedContract::new();
const GEAR_CONTRACT: TypedContract<GearParams> = TypedContract::new();
const RACK_PINION_CONTRACT: TypedContract<RackPinionParams> = TypedContract::new();
const SCREW_CONTRACT: TypedContract<ScrewParams> = TypedContract::new();
const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
#[path = "joints_inspect.rs"]
mod inspect;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
.at("SOLID", "CREATE")
.icon("joint_origin")
.contract(&ORIGIN_CONTRACT)
.output(output_schema_for::<MakeJointOriginResult>)
.params(
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size; z?: [x,y,z] the frame's z instead of the face's normal, along the edge when it runs that way, x chosen from z: an edge or corner picked as it is, kept as a point where it can't be found by name) | point: [x,y,z], z?: [x,y,z]; a circle's z?: [x,y,z] (a round edge's centre with the way out of it: its end by name when that is the frame it shows, else a point there); body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
),
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
CommandSpec::new("joint.create", "Joint", crate::checked!("joint.create", joint))
.at("SOLID", "ASSEMBLE")
.icon("joint")
.key("J")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<JointAddedResult>)
.params(
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball, or a relation: parallel|perpendicular|angle|point_on_plane|point_on_line|line_on_plane \
(ties only some directions or points, made where the parts are; several between two parts add up); a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"<face name>\"; z? for a point, end?, x? as joint.origin} | origin name); \
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
@@ -40,12 +74,27 @@ pub static COMMANDS: &[CommandSpec] = &[
kept with the joint and worked out again whenever the design changes, so the joint follows the parameters it names; a joint whose \
expression no longer works out (a parameter deleted) is left out of the solve with the reason in its errors",
),
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
CommandSpec::new("joint.as_built", "As-Built Joint", crate::checked!("joint.as_built", as_built))
.at("SOLID", "ASSEMBLE")
.icon("as_built")
.contract(&AS_BUILT_CONTRACT)
.output(output_schema_for::<JointAddedResult>)
.params(
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame; one on a or b is kept on its face or hole there, so the joint follows it as the design changes; \
name?; limits?: [[min, max] | null…]; quiet?: bool",
),
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
CommandSpec::new("joint.rigid_group", "Rigid Group", crate::checked!("joint.rigid_group", rigid_group))
.at("SOLID", "ASSEMBLE")
.icon("rigid_group")
.contract(&RIGID_GROUP_CONTRACT)
.output(output_schema_for::<RigidGroupResult>)
.params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", crate::checked!("joint.drive", drive))
.at("SOLID", "ASSEMBLE")
.icon("drive")
.contract(&DRIVE_CONTRACT)
.output(output_schema_for::<DriveJointResult>)
.params(
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; a value driven drops the expression joint.create gave it; clamped to the limits; the other joints of a \
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; requested: the values asked for, in the same form; \
@@ -54,36 +103,66 @@ pub static COMMANDS: &[CommandSpec] = &[
reason: why the joint can move nothing (suppressed: nothing is stored; its origins lost; its chain joined to nothing grounded → unconnected: \
the occurrence named; or, when nothing moved, the first held value's why); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)",
),
CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params(
CommandSpec::new("joint.reach", "Reach Point", crate::checked!("joint.reach", reach))
.icon("reach")
.contract(&REACH_CONTRACT)
.output(output_schema_for::<ReachResult>)
.params(
"occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move",
),
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
CommandSpec::new("joint.motion_link", "Motion Link", crate::checked!("joint.motion_link", motion_link))
.at("SOLID", "ASSEMBLE")
.icon("motion_link")
.contract(&MOTION_LINK_CONTRACT)
.output(output_schema_for::<SolveResult>)
.params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"),
CommandSpec::new("joint.gear", "Gear", gear).at("SOLID", "ASSEMBLE").icon("gear").params(
CommandSpec::new("joint.gear", "Gear", crate::checked!("joint.gear", gear))
.at("SOLID", "ASSEMBLE")
.icon("gear")
.contract(&GEAR_CONTRACT)
.output(output_schema_for::<CouplingResult>)
.params(
"a, b: joints that turn (revolute), b driven by a; ra, rb: the gears' pitch radii (or tooth counts): mm, an expression, or \
{circle: [x,y,z], occurrence?}: the round face there, its radius (read again whenever the design changes, so the ratio follows \
edits); internal?: bool (one is a ring gear: both turn the same way; by default b turns the other way); reverse?: bool; \
link?: index (replace that motion link) → link, ratio (b's degrees per a's); b keeps where it is, also when the link replaces \
another (at the current pose)",
),
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", rack_pinion).at("SOLID", "ASSEMBLE").icon("rack_pinion").params(
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", crate::checked!("joint.rack_pinion", rack_pinion))
.at("SOLID", "ASSEMBLE")
.icon("rack_pinion")
.contract(&RACK_PINION_CONTRACT)
.output(output_schema_for::<CouplingResult>)
.params(
"a, b: a joint that turns (revolute: the pinion) and one that slides (slider: the rack), in either order, b driven by a; \
radius: the pinion's pitch radius (mm, an expression, or {circle: [x,y,z], occurrence?}): the rack travels radius × the turn in \
radians; reverse?: bool; link?: index (replace that motion link) → link, ratio (mm per degree, or degrees per mm); b keeps \
where it is, also when the link replaces another (at the current pose)",
),
CommandSpec::new("joint.screw", "Screw", screw).at("SOLID", "ASSEMBLE").icon("screw").params(
CommandSpec::new("joint.screw", "Screw", crate::checked!("joint.screw", screw))
.at("SOLID", "ASSEMBLE")
.icon("screw")
.contract(&SCREW_CONTRACT)
.output(output_schema_for::<CouplingResult>)
.params(
"joint: a cylindrical joint (its turn drives its travel) | a, b: a joint that turns and one that slides, b driven by a; pitch: \
travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \
ratio (mm per degree, or degrees per mm); b keeps where it is, also when the link replaces another (at the current \
pose)",
),
CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params(
CommandSpec::new("joint.cam", "Cam", crate::checked!("joint.cam", cam_cmd))
.at("SOLID", "ASSEMBLE")
.icon("cam")
.contract(&CAM_CONTRACT)
.output(output_schema_for::<CamResult>)
.params(
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is, also when the link replaces another (at the current pose)",
),
CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.limits", "Joint Limits", crate::checked!("joint.limits", limits))
.icon("joint")
.contract(&LIMITS_CONTRACT)
.output(output_schema_for::<SolveResult>)
.params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.edit", "Edit Joint", crate::checked!("joint.edit", edit))
.icon("edit")
.contract(&EDIT_CONTRACT)
@@ -94,12 +173,26 @@ pub static COMMANDS: &[CommandSpec] = &[
its limits and expressions (a relation's values start again where its parts are); an edit that places it again (not a rename alone) \
meets a loop it closes as joint.create does, turning an angle a half turn where only that meets it",
),
CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params(
CommandSpec::new("joint.delete", "Delete Joint", crate::checked!("joint.delete", delete))
.icon("delete")
.contract(&DELETE_CONTRACT)
.output(output_schema_for::<DeleteJointResult>)
.params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", crate::checked!("joint.list", list))
.icon("list")
.noundo()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ListJointsResult>)
.params(
"→ joints (type, occurrences, values, limits; expressions: {offset?, angle?, values?} when they were typed as expressions), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
),
CommandSpec::new("joint.inspect", "Inspect Joint", inspect::inspect).icon("inspect").noundo().params(
CommandSpec::new("joint.inspect", "Inspect Joint", crate::checked!("joint.inspect", inspect::inspect))
.icon("inspect")
.noundo()
.contract(&INSPECT_CONTRACT)
.output(output_schema_for::<InspectJointResult>)
.params(
"joint?: id|name (default: every joint) → joints: [{id, name, type, suppressed, visible, flip, offset (mm), angle (deg), values: as joint.list \
gives them, dofs, expressions?: {offset?, angle?, values?}, a, b: {occurrence, occurrence_name, component, component_name, path \
(occurrence names from the top, `/` between), snap (as saved: type, pick, face, end, edge, corner, x, point, z, in the component's \
@@ -108,7 +201,11 @@ pub static COMMANDS: &[CommandSpec] = &[
unverified?: a name saved before exact names}], resolves, frame?: {origin, z, x} in the world, error?: why the origin is lost}, \
links: [{index, role: driver|driven, …as joint.list}], errors, conflicts}] (reads only: nothing changes)",
),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("joint.solve", "Solve Joints", crate::checked!("joint.solve", solve_cmd))
.icon("compute")
.contract(&SOLVE_CONTRACT)
.output(output_schema_for::<SolveResult>)
.params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", crate::checked!("inspect.interference", super::interference::interference))
.contract(&crate::schema::TypedContract::<crate::schema::interference::InterferenceParams>::new())
.output(crate::schema::output_schema_for::<crate::schema::interference::InterferenceResult>)
@@ -228,30 +325,52 @@ fn response_analysis(p: &Value) -> joints::Analysis {
}
/// A solve's answer; `quiet: true` in the command's params leaves out the per-occurrence lists.
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
let mut out = json!({
"conflicts": sol.conflicts.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
"errors": sol.errors.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
});
if !bool_(p, "quiet").unwrap_or(false) {
out["dof"] = sol.dof.iter().map(|(o, d)| json!({"occurrence": o, "dof": d})).collect();
out["fixed"] = json!(sol.fixed);
fn solve_report(sol: &joints::Solution, p: &Value) -> SolveReport {
let problems = |list: &[(u64, String, String)]| -> Vec<JointProblem> {
list.iter().map(|(joint, name, why)| JointProblem { joint: *joint, name: name.clone(), why: why.clone() }).collect()
};
let quiet = bool_(p, "quiet").unwrap_or(false);
SolveReport {
conflicts: problems(&sol.conflicts),
errors: problems(&sol.errors),
dof: (!quiet).then(|| sol.dof.iter().map(|(o, d)| OccurrenceDof { occurrence: *o, dof: *d }).collect()),
fixed: (!quiet).then(|| sol.fixed.clone()),
}
out
}
/// [`solve_report`] as JSON, for the commands that add to it.
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
serde_json::to_value(solve_report(sol, p)).unwrap_or_default()
}
/// A command's reply as JSON: the joint commands that have output schemas build their replies from
/// the types those describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// A joint's values as `joint.list` gives them: degrees for angles, mm for distances, each with
/// its name and limits. The numbers are rounded for reading (#10747); the joint keeps full
/// precision.
pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
pub(crate) fn named_value_list(j: &Joint, q: &[f64]) -> Vec<NamedValue> {
j.kind
.dofs()
.iter()
.enumerate()
.map(|(i, n)| {
let (v, angle) = (q.get(i).copied().unwrap_or(0.0), j.kind.is_angle(i));
json!({"name": n, "value": expr::round_reported(if angle { v.to_degrees() } else { v }), "unit": if angle { "deg" } else { "mm" },
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())] } else { [expr::round_reported(a), expr::round_reported(b)] })})
NamedValue {
name: (*n).to_string(),
value: expr::round_reported(if angle { v.to_degrees() } else { v }),
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
limits: j.limits.get(i).copied().flatten().map(|(a, b)| {
if angle {
[expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())]
} else {
[expr::round_reported(a), expr::round_reported(b)]
}
}),
}
})
.collect()
}
@@ -259,9 +378,14 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
/// A joint's values as the solver places it (after motion links, so a screw's travel follows its
/// turn), named as `joint.list` gives them.
pub(crate) fn effective_values(s: &Session, id: u64) -> Option<Vec<Value>> {
effective_value_list(s, id).map(|list| list.into_iter().map(|v| serde_json::to_value(v).unwrap_or_default()).collect())
}
/// [`effective_values`], typed.
fn effective_value_list(s: &Session, id: u64) -> Option<Vec<NamedValue>> {
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
let vals = joints::joint_values(&s.doc.assembly);
Some(named_values(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
Some(named_value_list(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
}
fn occurrence_name(s: &Session, id: u64) -> String {
@@ -293,7 +417,7 @@ pub(crate) fn cannot_move(s: &Session, sol: &joints::Solution, id: u64) -> Optio
/// Joint chains joined to nothing grounded, which the solver places none of: each chain's
/// occurrences and joints, by name.
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<FloatingChain> {
let placed = |o: u64| o == 0 || sol.world.contains_key(&o);
let errors: BTreeSet<u64> = sol.errors.iter().map(|e| e.0).collect();
let mut next: BTreeMap<u64, Vec<(u64, &Joint)>> = BTreeMap::new();
@@ -319,8 +443,10 @@ fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
}
}
}
out.push(json!({"occurrences": occs.iter().map(|o| occurrence_name(s, *o)).collect::<Vec<_>>(),
"joints": js.iter().map(|(_, n)| *n).collect::<Vec<_>>()}));
out.push(FloatingChain {
occurrences: occs.iter().map(|o| occurrence_name(s, *o)).collect(),
joints: js.iter().map(|(_, n)| (*n).to_string()).collect(),
});
}
out
}
@@ -501,7 +627,7 @@ fn same_frame(s: &Session, o: &JointOrigin, at: Vec3, z: Vec3) -> bool {
/// The longest face name a joint origin takes. A face that cuts split and merged keeps the names
/// of the faces around it (`<name>#[<name>,…]`): several hundred characters on a part a text or
/// a pattern of holes was cut through.
const MAX_FACE_NAME: usize = 16_384;
pub(crate) const MAX_FACE_NAME: usize = 16_384;
/// The component an occurrence places (0 for the root, or an unknown occurrence).
pub(crate) fn component_of(s: &Session, occurrence: u64) -> u64 {
@@ -754,21 +880,27 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
let mut q = j.values.clone();
q.resize(j.kind.dofs().len(), 0.0);
joints::clamp(&j, &mut q);
let round = |v: &Value| v.as_f64().map(|x| (x * 1e6).round() / 1e6).unwrap_or(f64::NAN);
let warnings: Vec<String> = named_values(&j, &q)
let round = |v: f64| (v * 1e6).round() / 1e6;
// As the reply shows them: a value that is not a number reads as null, and null is null.
let same = |a: f64, b: f64| a == b || (a.is_nan() && b.is_nan());
let warnings: Vec<String> = named_value_list(&j, &q)
.iter()
.zip(named_values(&j, &j.values))
.filter(|(now, was)| now["value"] != was["value"])
.zip(named_value_list(&j, &j.values))
.filter(|(now, was)| !same(now.value, was.value))
.map(|(now, was)| {
let unit = now["unit"].as_str().unwrap_or("");
let unit = match now.unit {
ValueUnit::Deg => "deg",
ValueUnit::Mm => "mm",
};
let [lo, hi] = now.limits.unwrap_or([f64::NAN; 2]);
format!(
"{} {} starts at {} {unit}, inside its limits [{}, {}], not at {} {unit}: the part moved there as it was joined",
j.name,
now["name"].as_str().unwrap_or(""),
round(&now["value"]),
round(&now["limits"][0]),
round(&now["limits"][1]),
round(&was["value"])
now.name,
round(now.value),
round(lo),
round(hi),
round(was.value)
)
})
.collect();
@@ -782,13 +914,7 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
if sol.conflicts.iter().any(|(c, ..)| *c == id) && close_by_half_turn(s, id, &sol) {
sol = resolve(s);
}
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["name"] = json!(name);
if !warnings.is_empty() {
out["warnings"] = json!(warnings);
}
Ok(out)
reply("joint.create", JointAddedResult { solved: solve_report(&sol, p), joint: id, name, warnings, param_warnings: Vec::new() })
}
/// Sets of angle values a new loop-closing joint tries a half turn round, at most.
@@ -892,7 +1018,7 @@ fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("there is already a joint origin `{name}`")));
}
s.doc_mut().assembly.origins.push(NamedOrigin { name: name.clone(), origin });
Ok(json!({"origin": name}))
reply(cmd, MakeJointOriginResult { origin: name, param_warnings: Vec::new() })
}
fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
@@ -1013,9 +1139,9 @@ fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
let mut made = Vec::new();
for other in ids.iter().skip(1) {
let r = as_built(s, &json!({"type": "rigid", "a": first, "b": other, "name": format!("Rigid Group {first}:{other}")}))?;
made.push(r["joint"].clone());
made.push(r["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint made has no id"))?);
}
Ok(json!({"joints": made}))
reply(cmd, RigidGroupResult { joints: made, param_warnings: Vec::new() })
}
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1087,41 +1213,48 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? }
};
let sol = solution(s, &BTreeMap::new(), response_analysis(p));
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["values"] = json!(effective_values(s, id));
if bool_(p, "continuous") == Some(true) {
out["continuous"] = json!(true);
}
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
let moved = super::motion::moved(&before, &super::motion::positions(s)).next().is_some();
out["moved"] = json!(moved);
// How motion stopped, as `motion::drive_with_contact` says: an object, or null.
let stop = |key: &str| r.get(key).filter(|v| v.is_object()).cloned();
let stopped_by_contact =
stop("stopped_by_contact").map(serde_json::from_value::<ContactStop>).transpose().map_err(|e| bad(cmd, e.to_string()))?;
let stopped_by_loop = stop("stopped_by_loop").map(serde_json::from_value::<LoopStop>).transpose().map_err(|e| bad(cmd, e.to_string()))?;
let mut out = DriveJointResult {
solved: solve_report(&sol, p),
joint: id,
values: effective_value_list(s, id),
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
stopped_by_contact,
stopped_by_loop,
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
requested: None,
held: None,
reason: None,
unconnected: None,
param_warnings: Vec::new(),
};
if let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) {
out["requested"] = json!(named_values(j, &requested));
out.requested = Some(named_value_list(j, &requested));
let held = if hold { Vec::new() } else { held_values(s, j, &requested, &driven, &r) };
if let Some(first) = held.first()
&& !moved
&& !out.moved
{
out["reason"] = first["why"].clone();
out.reason = Some(first.why.clone());
}
if !held.is_empty() {
out["held"] = json!(held);
out.held = Some(held);
}
}
if let Some((why, unconnected)) = cannot_move(s, &sol, id) {
out["reason"] = json!(why);
if let Some(o) = unconnected {
out["unconnected"] = json!(o);
}
out.reason = Some(why);
out.unconnected = unconnected;
}
Ok(out)
reply(cmd, out)
}
/// The driven values of `j` that ended up other than `requested`, each with why: a motion link
/// sets it from another coordinate (a screw's travel follows its turn), a limit holds it, or
/// contact or a closed loop stopped the motion short. Degrees and mm, as `values` (#9742).
fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec<Value> {
fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec<HeldValue> {
let now = joints::joint_values(&s.doc.assembly).remove(&j.id).unwrap_or_else(|| j.values.clone());
let names = j.kind.dofs();
let mut out = Vec::new();
@@ -1159,13 +1292,19 @@ fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &
} else {
continue;
};
out.push(json!({"name": name, "requested": shown(want), "value": shown(got), "unit": if angle { "deg" } else { "mm" }, "why": why}));
out.push(HeldValue {
name: name.to_string(),
requested: shown(want),
value: shown(got),
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
why,
});
}
out
}
/// Joints `joint.reach` takes at most in `joints`.
const MAX_REACH_JOINTS: usize = 1000;
pub(crate) const MAX_REACH_JOINTS: usize = 1000;
fn reach(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.reach";
@@ -1216,49 +1355,54 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
let sol = if response_analysis(p) == joints::Analysis::Placement { place(s) } else { resolve(s) };
let t = joints::occurrence_world(&s.doc, occ);
let got = solidcraft_doc::apply_point(&t, at);
let mut out = sol_json(&sol, p);
out["occurrence"] = json!(occurrence_name(s, occ));
out["point"] = json!([got.x, got.y, got.z]);
out["error_mm"] = json!(got.dist(to));
let mut there = got.dist(to) <= joints::REACH_TOL;
let mut error_deg = None;
if let Some((a, z)) = axis {
// From the sine too: the cosine alone loses small angles to rounding.
let v = solidcraft_doc::apply_vector(&t, a);
let deg = v.cross(z).len().atan2(v.dot(z)).to_degrees();
out["error_deg"] = json!(deg);
error_deg = Some(deg);
there &= deg <= joints::REACH_TOL;
}
out["reached"] = json!(there);
let name = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id);
// As joint.list gives them: with what motion links set.
let effective = joints::joint_values(&s.doc.assembly);
out["values"] = reached
let values = reached
.values
.keys()
.filter_map(|id| name(*id))
.map(|j| json!({"joint": j.id, "name": j.name, "values": named_values(j, effective.get(&j.id).unwrap_or(&j.values))}))
.map(|j| ReachedJoint { joint: j.id, name: j.name.clone(), values: named_value_list(j, effective.get(&j.id).unwrap_or(&j.values)) })
.collect();
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
out["stopped_by_limit"] = if reached.limits.is_empty() {
Value::Null
} else {
let stopped_by_limit = (!reached.limits.is_empty()).then(|| {
reached
.limits
.iter()
.filter_map(|(id, i, upper)| {
let j = name(*id)?;
Some(json!({"joint": j.name, "value": j.kind.dofs().get(*i), "limit": if *upper { "max" } else { "min" }}))
Some(LimitStop {
joint: j.name.clone(),
value: j.kind.dofs().get(*i).map(|d| (*d).to_string()),
limit: if *upper { LimitSide::Max } else { LimitSide::Min },
})
})
.collect()
});
let result = ReachResult {
solved: solve_report(&sol, p),
occurrence: occurrence_name(s, occ),
point: [got.x, got.y, got.z],
error_mm: got.dist(to),
error_deg,
reached: there,
values,
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
stopped_by_limit,
stopped_by_loop: reached.stopped_by_loop.and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
reason: r.err(),
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
param_warnings: Vec::new(),
};
out["stopped_by_loop"] = json!(reached.stopped_by_loop.and_then(name).map(|j| json!({"joint": j.name})));
if let Err(why) = r {
out["reason"] = json!(why);
}
if bool_(p, "continuous") == Some(true) {
out["continuous"] = json!(true);
}
Ok(out)
reply(cmd, result)
}
/// The centre of the box around an occurrence's bodies, in its own coordinates (its origin when
@@ -1289,7 +1433,7 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
let ratio = ratio * ratio_scale(s, a, ia, b, ib);
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None, lift: Vec::new() });
let sol = resolve(s);
Ok(sol_json(&sol, p))
reply(cmd, SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
}
/// A coupling length: a number (mm), an expression (kept as typed, so it follows the
@@ -1344,7 +1488,7 @@ fn gear(s: &mut Session, p: &Value) -> Result<Value> {
internal: bool_(p, "internal").unwrap_or(false),
reverse: reverse(p),
};
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
}
fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1352,7 +1496,7 @@ fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
let (a, b) = joint_pair(s, p, cmd)?;
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) };
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
}
fn screw(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1372,7 +1516,7 @@ fn screw(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) };
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
}
fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1406,30 +1550,32 @@ fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
c.reverse = cam::default_reverse(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))? != bool_(p, "flip").unwrap_or(false);
let offset = cam::follower_offset(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))?;
let reverse = c.reverse;
let mut out = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
let index = out["link"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(usize::MAX);
if let (Some(o), Some(l)) = (out.as_object_mut(), s.doc.assembly.links.get(index)) {
o.remove("ratio");
o.insert("lift".into(), lift_json(l, oscillating));
o.insert("reverse".into(), json!(reverse));
if !oscillating {
o.insert("follower_offset".into(), json!(offset));
}
}
Ok(out)
let coupled = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
let lift = s.doc.assembly.links.get(coupled.link).and_then(|l| lift_info(l, oscillating));
reply(
cmd,
CamResult {
solved: coupled.solved,
link: coupled.link,
lift,
reverse,
follower_offset: (!oscillating).then_some(offset),
param_warnings: Vec::new(),
},
)
}
/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their
/// difference, in degrees (`angle`) or mm.
fn lift_json(l: &MotionLink, angle: bool) -> Value {
/// difference, in degrees (`angle`) or mm; `None` for a table that is not whole.
fn lift_info(l: &MotionLink, angle: bool) -> Option<CamLift> {
// A table that is not whole (a hand-edited file) moves nothing, and says so.
if l.lift.len() != cam::CAM_SAMPLES {
return Value::Null;
return None;
}
let unit = |v: f64| if angle { v.to_degrees() } else { v };
let (lo, hi) = l.lift.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
let (lo, hi) = (unit(lo + l.offset), unit(hi + l.offset));
json!({"min": lo, "max": hi, "rise": hi - lo, "unit": if angle { "deg" } else { "mm" }})
Some(CamLift { min: lo, max: hi, rise: hi - lo, unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm } })
}
/// A cam's profile: a sketch's closed profile (kept by its outer loop's curve ids, so the sketch's
@@ -1491,7 +1637,7 @@ fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result<CamProfile> {
/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its
/// sizes, its offset leaving b where it is (the teeth mesh as they are now), for a new link and
/// one put in place of another alike.
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Value> {
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<CouplingResult> {
let links = &s.doc.assembly.links;
let replace = match p.get("link") {
None => None,
@@ -1544,10 +1690,7 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Va
}
};
let sol = resolve(s);
let mut out = sol_json(&sol, p);
out["link"] = json!(index);
out["ratio"] = json!(shown);
Ok(out)
Ok(CouplingResult { solved: solve_report(&sol, p), link: index, ratio: shown, param_warnings: Vec::new() })
}
fn is_angle(s: &Session, joint: u64, i: usize) -> bool {
@@ -1585,7 +1728,7 @@ fn limits(s: &mut Session, p: &Value) -> Result<Value> {
joints::clamp(j, &mut q);
j.values = q;
let sol = resolve(s);
Ok(sol_json(&sol, p))
reply(cmd, SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1711,7 +1854,10 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if doc.assembly.origins.len() == before {
return Err(bad(cmd, format!("no joint origin `{name}`")));
}
return Ok(json!({"deleted_origin": name, "joints": used}));
return reply(
cmd,
DeleteJointResult { deleted: JointDeleted::Origin { deleted_origin: name.to_string(), joints: used }, param_warnings: Vec::new() },
);
}
if let Some(i) = num(p, "link") {
let i = i.max(0.0) as usize;
@@ -1720,72 +1866,83 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "no such motion link"));
}
doc.assembly.links.remove(i);
return Ok(json!({"deleted_link": i}));
return reply(cmd, DeleteJointResult { deleted: JointDeleted::Link { deleted_link: i }, param_warnings: Vec::new() });
}
let id = joint_id(s, p.get("joint"), cmd)?;
delete_joints(s.doc_mut(), &[id]);
let sol = resolve(s);
let mut out = sol_json(&sol, p);
out["deleted"] = json!(id);
Ok(out)
reply(cmd, DeleteJointResult { deleted: JointDeleted::Joint { solved: solve_report(&sol, p), deleted: id }, param_warnings: Vec::new() })
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let sol = joints::solve(&s.doc, &st);
let vals = joints::joint_values(&s.doc.assembly);
let js: Vec<Value> = s
let js: Vec<ListedJoint> = s
.doc
.assembly
.joints
.iter()
.map(|j| {
let named = named_values(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default());
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
let mut out = json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)});
if !j.exprs.is_empty() {
out["expressions"] = json!(j.exprs);
let world = |o: &JointOrigin| {
s.doc.origin_world(&st, o).ok().map(|m| JointFrame { origin: [m[3][0], m[3][1], m[3][2]], z: [m[2][0], m[2][1], m[2][2]] })
};
ListedJoint {
id: j.id,
name: j.name.clone(),
kind: j.kind,
a: j.a.occurrence,
b: j.b.occurrence,
values: named_value_list(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default()),
flip: j.flip,
offset: j.offset,
suppressed: j.suppressed,
visible: s.joint_visible(j.id),
frame_a: world(&j.a),
frame_b: world(&j.b),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
}
out
})
.collect();
let links: Vec<Value> = s.doc.assembly.links.iter().map(|l| link_json(s, &st, l)).collect();
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
let mut out = sol_json(&sol, p);
out["joints"] = json!(js);
out["links"] = json!(links);
out["origins"] = json!(origins);
out["floating"] = json!(floating_chains(s, &sol));
Ok(out)
let out = ListJointsResult {
solved: solve_report(&sol, p),
joints: js,
links: s.doc.assembly.links.iter().map(|l| link_info(s, &st, l)).collect(),
origins: s.doc.assembly.origins.iter().map(|o| ListedOrigin { name: o.name.clone(), occurrence: o.origin.occurrence }).collect(),
floating: floating_chains(s, &sol),
param_warnings: Vec::new(),
};
reply("joint.list", out)
}
/// A motion link as `joint.list` and `joint.inspect` give it: ratio per degree, offset in b's
/// units, a coupling's definition (and why it can't be worked out), a cam's lift in place of a ratio.
fn link_json(s: &Session, st: &solidcraft_doc::ModelState, l: &MotionLink) -> Value {
fn link_info(s: &Session, st: &solidcraft_doc::ModelState, l: &MotionLink) -> LinkInfo {
let angle = is_angle(s, l.b, l.ib);
let offset = if angle { l.offset.to_degrees() } else { l.offset };
let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
let mut v = json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }});
if let Some(c) = &l.coupling {
v["coupling"] = json!(c);
if let Some(Err(e)) = joints::coupling_values(&s.doc, st, l) {
v["error"] = json!(e.to_string());
}
}
// A cam's follower follows its table, not a ratio: its travel instead.
if matches!(l.coupling, Some(Coupling::Cam(_)))
&& let Some(o) = v.as_object_mut()
{
o.remove("ratio");
o.insert("lift".into(), lift_json(l, angle));
let cam = matches!(l.coupling, Some(Coupling::Cam(_)));
LinkInfo {
a: l.a,
ia: l.ia,
b: l.b,
ib: l.ib,
ratio: (!cam).then_some(ratio),
offset,
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
coupling: l.coupling.clone(),
error: match &l.coupling {
Some(_) => joints::coupling_values(&s.doc, st, l).and_then(|r| r.err()).map(|e| e.to_string()),
None => None,
},
lift: cam.then(|| lift_info(l, angle)),
}
v
}
fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let sol = resolve(s);
Ok(sol_json(&sol, p))
reply("joint.solve", SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
}
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
+69 -80
View File
@@ -3,13 +3,17 @@
//! persistent name refers to), whether each origin is found today and why not, its kind, offset,
//! angle, flip, limits, values, expressions and the motion links it takes part in. Reads only.
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::joints::{self, Joint, JointOrigin, Snap, XRef};
use solidcraft_doc::naming::legacy_name;
use solidcraft_doc::persistent_name::{Base, EdgeName, FaceName, Role};
use solidcraft_doc::{Document, MAX_PLACEMENT_DEPTH, ModelState};
use super::{component_of, joint_id, link_json, named_values, occurrence_name};
use super::{component_of, joint_id, link_info, named_value_list, occurrence_name, reply};
use crate::schema::joints::{
EdgeReference, FaceReference, InspectJointResult, InspectedJoint, JointLink, JointSide, LinkRole, MadeBy, Reference, ReferenceRole, Referent,
SweptFrom, WorldFrame,
};
use crate::{Result, Session};
pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
@@ -21,7 +25,7 @@ pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let sol = joints::solve(&s.doc, &st);
let vals = joints::joint_values(&s.doc.assembly);
let out: Vec<Value> = ids
let out: Vec<InspectedJoint> = ids
.iter()
.filter_map(|id| s.doc.assembly.joints.iter().find(|j| j.id == *id))
.map(|j| {
@@ -29,50 +33,42 @@ pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
joint_json(s, &st, &sol, j, q)
})
.collect();
Ok(json!({ "joints": out }))
reply(cmd, InspectJointResult { joints: out, param_warnings: Vec::new() })
}
fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q: &[f64]) -> Value {
let links: Vec<Value> = s
fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q: &[f64]) -> InspectedJoint {
let links: Vec<JointLink> = s
.doc
.assembly
.links
.iter()
.enumerate()
.filter(|(_, l)| l.a == j.id || l.b == j.id)
.map(|(i, l)| {
let mut v = link_json(s, st, l);
v["index"] = json!(i);
v["role"] = json!(if l.b == j.id { "driven" } else { "driver" });
v
})
.map(|(i, l)| JointLink { index: i, role: if l.b == j.id { LinkRole::Driven } else { LinkRole::Driver }, link: link_info(s, st, l) })
.collect();
let why = |list: &[(u64, String, String)]| list.iter().filter(|(id, ..)| *id == j.id).map(|(.., why)| why.clone()).collect::<Vec<_>>();
let mut v = json!({
"id": j.id,
"name": j.name,
"type": j.kind,
"suppressed": j.suppressed,
"visible": s.joint_visible(j.id),
"flip": j.flip,
"offset": j.offset,
"angle": j.angle.to_degrees(),
"values": named_values(j, q),
"dofs": j.kind.dofs(),
"a": side_json(s, st, &j.a),
"b": side_json(s, st, &j.b),
"links": links,
"errors": why(&sol.errors),
"conflicts": why(&sol.conflicts),
});
if !j.exprs.is_empty() {
v["expressions"] = json!(j.exprs);
InspectedJoint {
id: j.id,
name: j.name.clone(),
kind: j.kind,
suppressed: j.suppressed,
visible: s.joint_visible(j.id),
flip: j.flip,
offset: j.offset,
angle: j.angle.to_degrees(),
values: named_value_list(j, q),
dofs: j.kind.dofs().iter().map(|d| (*d).to_string()).collect(),
a: side_json(s, st, &j.a),
b: side_json(s, st, &j.b),
links,
errors: why(&sol.errors),
conflicts: why(&sol.conflicts),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
}
v
}
/// One of a joint's origins: what it sits on, what it refers to, and where it is now.
fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> Value {
fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> JointSide {
let component = component_of(s, o.occurrence);
let component_name = if component == 0 {
"root".to_string()
@@ -80,24 +76,24 @@ fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> Value {
s.doc.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default()
};
let occurrence_name = if o.occurrence == 0 { "root".to_string() } else { occurrence_name(s, o.occurrence) };
let mut v = json!({
"occurrence": o.occurrence,
"occurrence_name": occurrence_name,
"component": component,
"component_name": component_name,
"path": occurrence_path(&s.doc, o.occurrence),
"snap": o.snap,
"references": references(&s.doc, &o.snap),
});
let mut v = JointSide {
occurrence: o.occurrence,
occurrence_name,
component,
component_name,
path: occurrence_path(&s.doc, o.occurrence),
snap: o.snap.clone(),
references: references(&s.doc, &o.snap),
resolves: false,
frame: None,
error: None,
};
match s.doc.origin_world(st, o) {
Ok(m) => {
v["resolves"] = json!(true);
v["frame"] = json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]], "x": [m[0][0], m[0][1], m[0][2]]});
}
Err(e) => {
v["resolves"] = json!(false);
v["error"] = json!(e.to_string());
v.resolves = true;
v.frame = Some(WorldFrame { origin: [m[3][0], m[3][1], m[3][2]], z: [m[2][0], m[2][1], m[2][2]], x: [m[0][0], m[0][1], m[0][2]] });
}
Err(e) => v.error = Some(e.to_string()),
}
v
}
@@ -118,36 +114,36 @@ fn occurrence_path(doc: &Document, occurrence: u64) -> String {
}
/// Every persistent name the snap holds, with what it refers to.
fn references(doc: &Document, snap: &Snap) -> Vec<Value> {
fn references(doc: &Document, snap: &Snap) -> Vec<Reference> {
let mut out = Vec::new();
let mut face = |role: &str, name: &Option<String>| {
let mut face = |role: ReferenceRole, name: &Option<String>| {
if let Some(n) = name {
out.push(with_role(role, face_ref(doc, n)));
out.push(Reference { role, to: Referent::Face(face_ref(doc, n)) });
}
};
match snap {
Snap::FaceCenter { face: f, x, .. } => {
face("face", f);
face(ReferenceRole::Face, f);
if let Some(XRef::Face { face: n, .. }) = x {
face("x", &Some(n.clone()));
face(ReferenceRole::X, &Some(n.clone()));
}
}
Snap::CircleCenter { face: f, end, x, .. } => {
face("face", f);
face("end", end);
face(ReferenceRole::Face, f);
face(ReferenceRole::End, end);
if let Some(XRef::Face { face: n, .. }) = x {
face("x", &Some(n.clone()));
face(ReferenceRole::X, &Some(n.clone()));
}
}
Snap::Edge { edge, face: f, corner, .. } => {
if let Some(e) = edge {
out.push(with_role("edge", edge_ref(doc, e)));
out.push(Reference { role: ReferenceRole::Edge, to: Referent::Edge(edge_ref(doc, e)) });
}
if let Some(n) = f {
out.push(with_role("face", face_ref(doc, n)));
out.push(Reference { role: ReferenceRole::Face, to: Referent::Face(face_ref(doc, n)) });
}
if let Some(e) = corner {
out.push(with_role("corner", edge_ref(doc, e)));
out.push(Reference { role: ReferenceRole::Corner, to: Referent::Edge(edge_ref(doc, e)) });
}
}
Snap::Frame { .. } | Snap::Point { .. } => {}
@@ -155,19 +151,14 @@ fn references(doc: &Document, snap: &Snap) -> Vec<Value> {
out
}
fn with_role(role: &str, mut v: Value) -> Value {
v["role"] = json!(role);
v
}
/// What a face's persistent name refers to: `F<feature>:<role>` names the feature that made the
/// face, and a side `side:<curve>` (or a later profile's `side:s<k>.<curve>`) the sketch curve
/// it was swept from. Names of another form are given as they are.
fn face_ref(doc: &Document, name: &str) -> Value {
let mut v = json!({ "name": name });
fn face_ref(doc: &Document, name: &str) -> FaceReference {
let mut v = FaceReference { name: name.to_string(), unverified: None, role_in_feature: None, feature: None, sketch: None };
let bare = match legacy_name(name) {
Some(n) => {
v["unverified"] = json!(true);
v.unverified = Some(true);
n
}
None => name,
@@ -175,38 +166,36 @@ fn face_ref(doc: &Document, name: &str) -> Value {
let Ok(face) = FaceName::parse(bare) else { return v };
let Some(fid) = face.made_by().map(|id| id.0) else { return v };
let stem = face.stem().to_string();
v["role_in_feature"] = json!(stem.strip_prefix(&format!("F{fid}:")).unwrap_or(&stem));
v.role_in_feature = Some(stem.strip_prefix(&format!("F{fid}:")).unwrap_or(&stem).to_string());
let Some(f) = doc.feature(fid) else {
v["feature"] = json!({"id": fid, "error": format!("feature {fid} is gone")});
v.feature = Some(MadeBy::Gone { id: fid, error: format!("feature {fid} is gone") });
return v;
};
v["feature"] = json!({"id": f.id, "name": f.name, "type": f.kind.type_name()});
v.feature = Some(MadeBy::Feature { id: f.id, name: f.name.clone(), kind: f.kind.type_name().to_string() });
// A side swept from a sketch curve (a later profile's too, `side:s<k>.<curve>`).
if let Base::Made { role: Role::Side { curve, .. }, .. } = &face.base {
let found = f.kind.input_sketches().into_iter().find_map(|id| {
// design-only: a joint names a curve of one of the design's sketches.
let sk = doc.sketch(id).ok()?;
let c = sk.curves.iter().find(|c| c.id == *curve)?;
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").cloned()).unwrap_or(Value::Null);
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").and_then(Value::as_str).map(str::to_string));
let name = doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Some(json!({"id": id, "name": name, "curve": curve, "curve_type": curve_type}))
Some(SweptFrom::Curve { id, name, curve: curve.clone(), curve_type })
});
v["sketch"] = found.unwrap_or_else(|| json!({"curve": curve, "error": format!("no sketch of {} has a curve `{curve}`", f.name)}));
v.sketch = Some(
found.unwrap_or_else(|| SweptFrom::Missing { curve: curve.clone(), error: format!("no sketch of {} has a curve `{curve}`", f.name) }),
);
}
v
}
/// An edge's persistent name (`<face A>|<face B>`, `~k` for one of several between them) with
/// what each of its faces refers to.
fn edge_ref(doc: &Document, name: &str) -> Value {
fn edge_ref(doc: &Document, name: &str) -> EdgeReference {
let bare = legacy_name(name).unwrap_or(name);
let faces: Vec<Value> = match EdgeName::parse(bare) {
let faces: Vec<FaceReference> = match EdgeName::parse(bare) {
Ok(e) => e.faces.iter().map(|f| face_ref(doc, &f.to_string())).collect(),
Err(_) => Vec::new(),
};
let mut v = json!({"name": name, "faces": faces});
if legacy_name(name).is_some() {
v["unverified"] = json!(true);
}
v
EdgeReference { name: name.to_string(), faces, unverified: legacy_name(name).is_some().then_some(true) }
}
+3 -3
View File
@@ -159,9 +159,9 @@ fn material_reply(cmd: &str, m: &UserMaterial) -> Result<MaterialReply> {
Ok(out)
}
/// [`material_reply`] as JSON, for `material.list` (`cmd/inspect.rs`), which isn't converted yet.
pub(super) fn material_json(m: &UserMaterial) -> Result<Value> {
reply("material.list", material_reply("material.list", m)?)
/// [`material_reply`] for `material.list` (`cmd/inspect.rs`).
pub(super) fn listed_material(m: &UserMaterial) -> Result<MaterialReply> {
material_reply("material.list", m)
}
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
+37 -29
View File
@@ -11,6 +11,7 @@ use solidcraft_geom::{Mesh, UnitVec3};
use super::measure_shape::{Shape, Slot, add_round, centre};
use crate::params::bad;
use crate::schema::inspect::{MeasureKind, MeasureResult, Measured, MeasuredItems};
use crate::{Result, Sel, Session};
/// Geometry of a picked item, as points or triangles.
@@ -665,16 +666,20 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces, bodies, sketch curves or profiles)"))?,
);
}
let mut out = json!({"items": described.iter().map(|d| d.json.clone()).collect::<Vec<_>>()});
let mut out =
MeasuredItems { items: described.iter().map(|d| d.json.as_object().cloned().unwrap_or_default()).collect(), ..MeasuredItems::default() };
if let [one] = described.as_slice() {
for k in ["diameter", "radius", "circumference", "angle_deg", "arc_length", "center", "slot"] {
if let Some(v) = one.json.get(k) {
out[k] = v.clone();
}
}
let copy = |k: &str| one.json.get(k).cloned();
out.diameter = copy("diameter");
out.radius = copy("radius");
out.circumference = copy("circumference");
out.angle_deg = copy("angle_deg");
out.arc_length = copy("arc_length");
out.center = copy("center");
out.slot = copy("slot");
if let Some(sl) = &one.slot {
out["center_from"] = json!(sl.centres.0);
out["center_to"] = json!(sl.centres.1);
out.center_from = Some(sl.centres.0.into());
out.center_to = Some(sl.centres.1.into());
}
}
if let [a, b] = described.as_slice() {
@@ -698,42 +703,45 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
max = Some(exact);
}
if let (Some((p, q)), Some(d)) = (near, min) {
out["distance_mm"] = json!(d);
out["from"] = json!(p);
out["to"] = json!(q);
out["delta"] = json!(q - p);
out["min"] = json!(d);
out.set_closest(d, p, q);
}
let unbounded = |i: usize| matches!(sels.get(i), Some(Sel::Plane { .. } | Sel::Axis { .. }));
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape, near.map(|(p, _)| p), (unbounded(0), unbounded(1)))
.filter(|(d, p, q)| d.is_finite() && p.is_finite() && q.is_finite())
{
out["distance_mm"] = json!(d);
out["from"] = json!(p);
out["to"] = json!(q);
out["delta"] = json!(q - p);
out["min"] = json!(d);
out.set_closest(d, p, q);
}
if let (Some((p, q)), Some(d)) = (far, max) {
out["max"] = json!(d);
out["max_from"] = json!(p);
out["max_to"] = json!(q);
out["max_delta"] = json!(q - p);
out.max = Some(d);
out.max_from = Some(p.into());
out.max_to = Some(q.into());
out.max_delta = Some((q - p).into());
}
if let Some(c) = c {
out["center_distance"] = json!(c.distance);
out["center_from"] = json!(c.from);
out["center_to"] = json!(c.to);
out["center_delta"] = json!(c.to - c.from);
out.center_distance = Some(c.distance);
out.center_from = Some(c.from.into());
out.center_to = Some(c.to.into());
out.center_delta = Some((c.to - c.from).into());
}
out["measure"] = json!(if c.is_some() { "center" } else { "min" });
out.measure = Some(if c.is_some() { MeasureKind::Center } else { MeasureKind::Min });
if let (Some(da), Some(db)) = (a.dir, b.dir) {
let both_faces = matches!(sels.as_slice(), [Sel::Face { .. }, Sel::Face { .. }]);
let cos = if both_faces { da.dot(db) } else { corner_cos(a.shape, b.shape).unwrap_or_else(|| da.dot(db).abs()) };
out["angle_deg"] = json!(cos.clamp(-1.0, 1.0).acos().to_degrees());
out.angle_deg = Some(json!(cos.clamp(-1.0, 1.0).acos().to_degrees()));
}
}
Ok(out)
serde_json::to_value(MeasureResult { measured: Measured::Items(Box::new(out)), param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
}
impl MeasuredItems {
/// The closest distance between two items and where it is: `distance_mm` and `min` are one.
fn set_closest(&mut self, d: f64, p: Vec3, q: Vec3) {
self.distance_mm = Some(d);
self.min = Some(d);
self.from = Some(p.into());
self.to = Some(q.into());
self.delta = Some((q - p).into());
}
}
/// Farthest pair of points between two pieces of geometry (on their samples and vertices).
+9 -3
View File
@@ -85,8 +85,12 @@ mod point_cloud;
mod script;
mod section;
pub mod settings;
pub(crate) use clipboard::{component_path_form, copy_keys};
pub(crate) use component::matrix::{rigid as rigid_matrix, tolerance as matrix_tolerance};
pub(crate) use component::{base_transform, move_vector};
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
pub(crate) use script::step_params;
#[cfg(test)]
mod kernel_heal_tests;
mod panels;
@@ -102,15 +106,15 @@ mod render_ui;
mod script_tests;
mod sheet;
pub(crate) use sheet::{
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
sheet_rule_name, then_list,
FlangeType, ThenStep, bend_table_format, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position,
perforation_pattern, sheet_points, sheet_rule_name, then_list,
};
mod silhouette;
mod sketch;
pub(crate) use sketch::author_path_for_rebind;
pub(crate) use sketch::collapse_reason;
pub(crate) use sketch::created as sketch_created;
pub(crate) use sketch::{PLANE_REF_FORMS, custom_plane_form};
pub(crate) use sketch::{PLANE_REF_FORMS, custom_plane_form, point_arg_form};
mod canvas_sketch;
mod sketch_3d;
mod sketch_canvas;
@@ -118,6 +122,7 @@ pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_construction;
mod sketch_create;
pub(crate) use sketch_create::{AT_MUST, MAX_TEXT_EDITS};
mod sketch_divide;
mod sketch_ends;
mod sketch_equation;
@@ -126,6 +131,7 @@ mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
pub(crate) use sketch_project::{RefForm, ref_form};
mod sketch_store;
mod surface;
mod surface_analysis;
+38 -14
View File
@@ -16,8 +16,10 @@ use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, plac
use crate::params::{bad, bool_, str_, vec3};
use crate::schema::motion::{
ContactCheck, ContactCollision, ContactCreateParams, ContactEditParams, ContactEnableParams, ContactNoParams, ContactSetChange,
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, FindLimitsParams, FindLimitsResult, MotionListParams,
MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo, MotionStudyParams, MotionStudySaved,
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, ExplodeCreateParams, ExplodeCreated, ExplodeDeleteParams,
ExplodeDeleted, ExplodeListParams, ExplodeMoveInfo, ExplodeShowParams, ExplodeShown, ExplodeViews, ExplodedViewInfo, FindLimitsParams,
FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo,
MotionStudyParams, MotionStudySaved,
};
use crate::schema::motion_play::{
DriveReport, ExportParams, PartPose, PlayParams, Pose, StudyExport, StudyExported, StudyPlayed, StudySample, UnmovedJoint,
@@ -53,6 +55,10 @@ const CONTACT_EDIT_CONTRACT: TypedContract<ContactEditParams> = TypedContract::n
const STUDY_CONTRACT: TypedContract<MotionStudyParams> = TypedContract::new();
const STUDY_DELETE_CONTRACT: TypedContract<MotionStudyDeleteParams> = TypedContract::new();
const STUDY_LIST_CONTRACT: TypedContract<MotionListParams> = TypedContract::new();
const EXPLODE_CREATE_CONTRACT: TypedContract<ExplodeCreateParams> = TypedContract::new();
const EXPLODE_SHOW_CONTRACT: TypedContract<ExplodeShowParams> = TypedContract::new();
const EXPLODE_LIST_CONTRACT: TypedContract<ExplodeListParams> = TypedContract::new();
const EXPLODE_DELETE_CONTRACT: TypedContract<ExplodeDeleteParams> = TypedContract::new();
const PLAY_CONTRACT: TypedContract<PlayParams> = TypedContract::new();
const EXPORT_CONTRACT: TypedContract<ExportParams> = TypedContract::new();
@@ -175,16 +181,32 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&STUDY_DELETE_CONTRACT)
.output(output_schema_for::<MotionStudyDeleted>)
.params("study"),
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").enabled(has_parts_to_explode).params(
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
),
CommandSpec::new("explode.show", "Show Exploded View", explode_show)
CommandSpec::new("explode.create", "Exploded View", crate::checked!("explode.create", explode_create))
.at("SOLID", "ASSEMBLE")
.icon("explode")
.enabled(has_parts_to_explode)
.contract(&EXPLODE_CREATE_CONTRACT)
.output(output_schema_for::<ExplodeCreated>)
.params(
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
),
CommandSpec::new("explode.show", "Show Exploded View", crate::checked!("explode.show", explode_show))
.icon("explode")
.noundo()
.contract(&EXPLODE_SHOW_CONTRACT)
.output(output_schema_for::<ExplodeShown>)
.params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"),
CommandSpec::new("explode.list", "List Exploded Views", explode_list).icon("list").noundo(),
CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).icon("delete").params("view"),
CommandSpec::new("explode.list", "List Exploded Views", crate::checked!("explode.list", explode_list))
.icon("list")
.noundo()
.contract(&EXPLODE_LIST_CONTRACT)
.output(output_schema_for::<ExplodeViews>),
CommandSpec::new("explode.delete", "Delete Exploded View", crate::checked!("explode.delete", explode_delete))
.icon("delete")
.contract(&EXPLODE_DELETE_CONTRACT)
.output(output_schema_for::<ExplodeDeleted>)
.params("view"),
];
const MAX_ITEMS: usize = 10_000;
@@ -1233,7 +1255,8 @@ fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
let name =
str_(p, "name").map(str::to_string).unwrap_or_else(|| free_name("Exploded View", s.doc.assembly.exploded.iter().map(|v| v.name.as_str())));
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
let moves = moves.iter().map(ExplodeMoveInfo::from).collect();
typed_reply(cmd, ExplodeCreated { view: id, name, moves, param_warnings: Vec::new() })
}
fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1242,7 +1265,7 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
s.pending_moves.clear();
s.exploded.clear();
s.revision += 1;
return Ok(json!({"view": null}));
return typed_reply(cmd, ExplodeShown { view: None, name: None, param_warnings: Vec::new() });
}
let i = view_index(s, p, cmd)?;
let v = s.doc.assembly.exploded.get(i).cloned().ok_or_else(|| bad(cmd, "view"))?;
@@ -1254,7 +1277,7 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
s.exploded.clone_from(&moves);
s.pending_moves = moves;
s.revision += 1;
Ok(json!({"view": v.id, "name": v.name}))
typed_reply(cmd, ExplodeShown { view: Some(v.id), name: Some(v.name), param_warnings: Vec::new() })
}
/// Whether the pending moves are an exploded view shown (`explode.show`), which places the
@@ -1287,13 +1310,14 @@ fn exploded_at(moves: &[(u64, Mat, Vec3)], amount: f64) -> std::collections::BTr
}
fn explode_list(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"views": s.doc.assembly.exploded}))
let views = s.doc.assembly.exploded.iter().map(ExplodedViewInfo::from).collect();
typed_reply("explode.list", ExplodeViews { views, param_warnings: Vec::new() })
}
fn explode_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = view_index(s, p, "explode.delete")?;
let v = s.doc_mut().assembly.exploded.remove(i);
Ok(json!({"deleted": v.name}))
typed_reply("explode.delete", ExplodeDeleted { deleted: v.name, param_warnings: Vec::new() })
}
/// World position of a point of an occurrence (tests).
+76
View File
@@ -108,6 +108,22 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"target or legacy id, expected_revision?: reopen retained state, activate and request focus"
),
command!(
"panel.focus.next",
"fit",
"Focus Next Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.focus.previous",
"fit",
"Focus Previous Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.list",
"list",
@@ -124,6 +140,14 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"target or legacy id, destination: new_section, join_tab or relative, activate?: false, expected_revision?"
),
command!(
"panel.move.next_zone",
"move",
"Move Panel to Next Zone",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.native_close",
"restore",
@@ -180,4 +204,56 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"first, second, first_size, second_size, expected_revision?: conserve adjacent dock pair extent"
),
command!(
"panel.sash.down",
"scale",
"Resize Panel Sash Down",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Down"),
command!(
"panel.sash.left",
"scale",
"Resize Panel Sash Left",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Left"),
command!(
"panel.sash.reset",
"restore",
"Reset Focused Panel Sash",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.sash.right",
"scale",
"Resize Panel Sash Right",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Right"),
command!(
"panel.sash.up",
"scale",
"Resize Panel Sash Up",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Up"),
command!(
"panel.toggle",
"fold",
"Toggle Focused Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
];
+115 -63
View File
@@ -10,8 +10,11 @@ use solidcraft_sketch::{Analysis, ConstraintKind, CurveKind, Hold, Sketch, solve
use super::{CommandSpec, sketch_store};
use crate::params::{bad, bool_, str_};
use crate::schema::parameters::{
AddedValue, ParamAddParams, ParamAdded, ParamCommentParams, ParamCommented, ParamDeleted, ParamFavoriteParams, ParamFavorited, ParamMoveParams,
ParamMoved, ParamNameParams, ParamRenameParams, ParamRenamed,
AddedValue, Completion, CompletionKind, DrivenFeature, EvalKind, ExprCompleteParams, ExprCompletions, ExprEvaluateParams, ExprEvaluated,
ExprEvaluatedBody, FailedFeature, ParamAddParams, ParamAdded, ParamChangeParams, ParamChanged, ParamChangedBody, ParamChangedOne,
ParamCommentParams, ParamCommented, ParamDeleted, ParamExportParams, ParamExportResult, ParamFavoriteParams, ParamFavorited, ParamGraphParams,
ParamGraphResult, ParamImportParams, ParamImportResult, ParamListParams, ParamListResult, ParamMoveParams, ParamMoved, ParamNameParams,
ParamRename, ParamRenameParams, ParamRenamed, ParamUsersParams, ParamUsersResult, ParameterRow, ParameterUser, SwitchedFeature,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -22,9 +25,22 @@ const NAME_CONTRACT: TypedContract<ParamNameParams> = TypedContract::new();
const COMMENT_CONTRACT: TypedContract<ParamCommentParams> = TypedContract::new();
const FAVORITE_CONTRACT: TypedContract<ParamFavoriteParams> = TypedContract::new();
const MOVE_CONTRACT: TypedContract<ParamMoveParams> = TypedContract::new();
const CHANGE_CONTRACT: TypedContract<ParamChangeParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ParamListParams> = TypedContract::new();
const USERS_CONTRACT: TypedContract<ParamUsersParams> = TypedContract::new();
const GRAPH_CONTRACT: TypedContract<ParamGraphParams> = TypedContract::new();
const EXPORT_CONTRACT: TypedContract<ParamExportParams> = TypedContract::new();
const IMPORT_CONTRACT: TypedContract<ParamImportParams> = TypedContract::new();
const EVALUATE_CONTRACT: TypedContract<ExprEvaluateParams> = TypedContract::new();
const COMPLETE_CONTRACT: TypedContract<ExprCompleteParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
CommandSpec::new("parameters.change", "Change Parameters", crate::checked!("parameters.change", change_param))
.at("SOLID", "MODIFY")
.icon("params")
.contract(&CHANGE_CONTRACT)
.output(output_schema_for::<ParamChanged>)
.params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\"|text (new parameters default to their value's unit, a length the design's units; \
a text parameter's expression is its text, quotes round it optional: sketch texts' words are built from it), comment?, \
favorite?: bool, new_name?: rename (references follow; an expression `new = value` renames to `new` and sets value, \
@@ -34,7 +50,13 @@ pub static COMMANDS: &[CommandSpec] = &[
(the configuration rules that switch a feature's suppression and read a parameter whose value changed run in their order, as on \
activation; driven parameters and restrictions wait for a configuration edit or activation)",
),
CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params(
CommandSpec::new("parameters.list", "List Parameters", crate::checked!("parameters.list", list))
.icon("list")
.noundo()
.doc_only()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ParamListResult>)
.params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value (mm/deg in every \
reply), display_value (in its own unit), source, feature/input, comment, favorite, uses, used_by, error) and cycles",
),
@@ -68,30 +90,42 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&COMMENT_CONTRACT)
.output(output_schema_for::<ParamCommented>)
.params("name, comment"),
CommandSpec::new("parameters.users", "Parameter Users", users)
CommandSpec::new("parameters.users", "Parameter Users", crate::checked!("parameters.users", users))
.icon("graph")
.noundo()
.contract(&USERS_CONTRACT)
.output(output_schema_for::<ParamUsersResult>)
.params("name → users (parameters, feature inputs, dimensions) and the features it drives"),
CommandSpec::new("parameters.graph", "Parameter Graph", graph)
CommandSpec::new("parameters.graph", "Parameter Graph", crate::checked!("parameters.graph", graph))
.icon("graph")
.noundo()
.contract(&GRAPH_CONTRACT)
.output(output_schema_for::<ParamGraphResult>)
.params("→ nodes, edges [user, used] (users include Feature:<name>), cycles, errors"),
CommandSpec::new("parameters.export", "Export Parameters", export)
CommandSpec::new("parameters.export", "Export Parameters", crate::checked!("parameters.export", export))
.icon("export")
.noundo()
.contract(&EXPORT_CONTRACT)
.output(output_schema_for::<ParamExportResult>)
.params("format?: csv|json (default csv, or from the path), path?: write to a file → text"),
CommandSpec::new("parameters.import", "Import Parameters", import)
CommandSpec::new("parameters.import", "Import Parameters", crate::checked!("parameters.import", import))
.icon("import")
.contract(&IMPORT_CONTRACT)
.output(output_schema_for::<ParamImportResult>)
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
CommandSpec::new("expr.evaluate", "Evaluate Expression", crate::checked!("expr.evaluate", evaluate))
.icon("params")
.noundo()
.doc_only()
.contract(&EVALUATE_CONTRACT)
.output(output_schema_for::<ExprEvaluated>)
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…; text: the text a text parameter would hold, as `text`) → ok, value (mm/rad), display_value, references, error"),
CommandSpec::new("parameters.complete", "Complete Expression", complete)
CommandSpec::new("parameters.complete", "Complete Expression", crate::checked!("parameters.complete", complete))
.icon("params")
.noundo()
.doc_only()
.contract(&COMPLETE_CONTRACT)
.output(output_schema_for::<ExprCompletions>)
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
];
@@ -108,8 +142,8 @@ fn param_name<'a>(cmd: &str, p: &'a Value) -> Result<&'a str> {
}
/// Features that fail (name and error).
fn failing(s: &Session) -> Vec<Value> {
s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect()
fn failing(s: &Session) -> Vec<FailedFeature> {
FailedFeature::of(&s.model.results)
}
pub(super) fn known(s: &Session, name: &str) -> bool {
@@ -130,19 +164,20 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(n) = str_(p, "delete") {
s.doc_mut().remove_param(n.trim())?;
s.settle_value_edit(&solved)?;
return Ok(json!({"deleted": n.trim()}));
return reply(cmd, ParamChanged { body: ParamChangedBody::Deleted { deleted: n.trim().to_string() }, param_warnings: Vec::new() });
}
let mut name = param_name(cmd, p)?.to_string();
let mut out = json!({});
let mut renamed: Option<ParamRename> = None;
let mut changed_through: Option<String> = None;
if let Some(new) = str_(p, "new_name") {
let n = s.doc_mut().rename_param(&name, new)?;
out["renamed"] = json!({"from": name, "to": new.trim(), "references_updated": n});
renamed = Some(ParamRename { from: name, to: new.trim().to_string(), references_updated: n });
name = new.trim().to_string();
}
let has_expr = p.get("expression").is_some() || p.get("value").is_some();
let mut e = if has_expr {
Some(expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?)
} else if out.get("renamed").is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
} else if renamed.is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
None
} else {
return Err(bad(cmd, "`expression` must be a number or text"));
@@ -151,7 +186,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
// `d7`, a feature input's) is renamed, references following, and takes the expression. A
// text parameter's text is only text.
if let Some((new, rhs)) = e.as_deref().filter(|_| !is_text(s, &name, str_(p, "unit"))).and_then(expr::definition) {
if out.get("renamed").is_some() {
if renamed.is_some() {
return Err(bad(cmd, format!("`{new} = …` renames `{name}` already: give `new_name` or `name = expression`, not both")));
}
if rhs.is_empty() {
@@ -162,7 +197,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
// `w = 20` shows `w`, and `w = 30` typed over it sets `w`.
let refers_to = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| *n == name).map(|(_, e, _)| e);
if refers_to.as_deref().map(str::trim) == Some(new.as_str()) && s.doc.param(&new).is_some() {
out["changed_through"] = json!(name);
changed_through = Some(name);
name = new.clone();
}
if new != name {
@@ -170,7 +205,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("there is already a parameter `{new}`: type `{new}` to use it, or change it in Parameters")));
}
let n = s.doc_mut().rename_param(&name, &new)?;
out["renamed"] = json!({"from": name, "to": new, "references_updated": n});
renamed = Some(ParamRename { from: name, to: new.clone(), references_updated: n });
name = new;
}
e = Some(rhs);
@@ -197,20 +232,20 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
}
s.settle_value_edit(&solved)?;
let (vals, _) = s.doc.param_values();
if !switched.is_empty() {
out["switched"] = json!(switched);
}
out["name"] = json!(name);
out["expression"] = json!(e);
if let Some(t) = s.doc.text_param(&name) {
out["text"] = json!(t.text);
}
out["value"] = json!(vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))));
out["display_value"] = json!(s.doc.param_display_value(&vals, &name).map(expr::round_reported));
out["recomputed"] = json!(s.model.last_recomputed);
out["restored"] = json!(s.model.last_restored);
out["errors"] = json!(failing(s));
Ok(out)
let one = ParamChangedOne {
renamed,
changed_through,
switched,
text: s.doc.text_param(&name).map(|t| t.text.clone()),
value: vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))),
display_value: s.doc.param_display_value(&vals, &name).map(expr::round_reported),
recomputed: s.model.last_recomputed,
restored: s.model.last_restored,
errors: failing(s),
name,
expression: e,
};
reply(cmd, ParamChanged { body: ParamChangedBody::One(Box::new(one)), param_warnings: Vec::new() })
}
/// The point a length dimension keeps when its value changes: the first it measures from (a
@@ -308,19 +343,15 @@ fn change_many(s: &mut Session, changes: &Value, solved: &crate::SketchValues) -
}
s.settle_value_edit(solved)?;
let (vals, _) = s.doc.param_values();
let values: serde_json::Map<String, Value> =
list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v)))))).collect();
let mut out = json!({"changed": values, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored, "errors": failing(s)});
if !switched.is_empty() {
out["switched"] = json!(switched);
}
Ok(out)
let changed = list.iter().map(|(n, _)| (n.clone(), vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v))))).collect();
let body = ParamChangedBody::Many { changed, recomputed: s.model.last_recomputed, restored: s.model.last_restored, errors: failing(s), switched };
reply(cmd, ParamChanged { body, param_warnings: Vec::new() })
}
/// The configuration rules that switch a feature's suppression and read a parameter this edit
/// changed (`Document::apply_suppression_rules`), applied before the design is rebuilt, so that
/// one rebuild has them: `[{feature, name, suppressed}]` for each feature they switched.
fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String, expr::Value>) -> Vec<Value> {
fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String, expr::Value>) -> Vec<SwitchedFeature> {
if s.doc.configs.rules.is_empty() {
return Vec::new();
}
@@ -329,12 +360,13 @@ fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String,
.into_iter()
.map(|(id, suppressed)| {
let name = s.doc.features.iter().find(|f| f.id == id).map(|f| f.name.clone());
json!({"feature": id, "name": name, "suppressed": suppressed})
SwitchedFeature { feature: id, name, suppressed }
})
.collect()
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.list";
let filter = match (bool_(p, "favorites"), str_(p, "filter")) {
(Some(true), _) => "favorites".to_string(),
(_, Some(f)) => f.to_ascii_lowercase(),
@@ -354,7 +386,9 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
// Favourites first; otherwise the table order (user, sketch, feature).
rows.sort_by_key(|r| !r.favorite);
let cycles = solidcraft_doc::cycles(&s.doc.all_param_exprs());
Ok(json!({"parameters": rows, "count": rows.len(), "cycles": cycles}))
let count = rows.len();
let parameters = rows.into_iter().map(ParameterRow::from).collect();
reply(cmd, ParamListResult { parameters, count, cycles: cycles.into_iter().collect(), param_warnings: Vec::new() })
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
@@ -438,17 +472,24 @@ fn users(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no parameter `{name}`")));
}
let users = s.doc.param_users(name);
let drives: Vec<Value> =
s.doc.features_using_param(name).iter().filter_map(|id| s.doc.feature(*id)).map(|f| json!({"id": f.id, "name": f.name})).collect();
Ok(json!({"name": name, "users": users, "drives": drives}))
let users = users.into_iter().map(ParameterUser::from).collect();
let drives = s
.doc
.features_using_param(name)
.iter()
.filter_map(|id| s.doc.feature(*id))
.map(|f| DrivenFeature { id: f.id, name: f.name.clone() })
.collect();
reply(cmd, ParamUsersResult { name: name.to_string(), users, drives, param_warnings: Vec::new() })
}
fn graph(s: &mut Session, _p: &Value) -> Result<Value> {
let cmd = "parameters.graph";
let (_, errs) = s.doc.param_values();
let nodes: Vec<String> = s.doc.param_defs().into_iter().map(|d| d.name).collect();
let edges: Vec<[String; 2]> = s.doc.param_graph().into_iter().map(|(a, b)| [a, b]).collect();
let cycles = solidcraft_doc::cycles(&s.doc.all_param_exprs());
Ok(json!({"nodes": nodes, "edges": edges, "cycles": cycles, "errors": errs}))
reply(cmd, ParamGraphResult { nodes, edges, cycles: cycles.into_iter().collect(), errors: errs, param_warnings: Vec::new() })
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
@@ -477,7 +518,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = path {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
}
Ok(json!({"format": format, "path": path, "text": text}))
reply(cmd, ParamExportResult { format, path: path.map(str::to_string), text, param_warnings: Vec::new() })
}
/// Largest parameter table we read from a file.
@@ -495,7 +536,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let solved = s.solved_sketches();
let names = s.doc_mut().import_params(&text)?;
s.settle_value_edit(&solved)?;
Ok(json!({"imported": names, "errors": failing(s)}))
reply(cmd, ParamImportResult { imported: names, errors: failing(s), param_warnings: Vec::new() })
}
fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
@@ -504,7 +545,8 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
let unit = str_(p, "unit");
// A text parameter's expression is its text.
if unit.is_some_and(solidcraft_doc::text_params::is_text_unit) {
return Ok(json!({"ok": true, "kind": "text", "text": solidcraft_doc::text_params::unquote(&e), "references": []}));
let body = ExprEvaluatedBody::Text { ok: true, kind: "text".into(), text: solidcraft_doc::text_params::unquote(&e), references: Vec::new() };
return reply(cmd, ExprEvaluated { body, param_warnings: Vec::new() });
}
if let Some(u) = unit
&& expr::unit_info(u).is_none()
@@ -531,7 +573,7 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
});
// `name = expression` is worth its expression, and names what it would define (#4071).
let defines = expr::definition(&e).map(|(n, _)| n);
let mut out = match r {
let body = match r {
Ok((v, k)) => {
let value = match k {
Kind::Length => expr::Value::length(v),
@@ -543,17 +585,27 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
(None, Kind::Angle) => v.to_degrees(),
(None, _) => v,
};
json!({"ok": true, "value": expr::round_reported(v), "kind": k, "display_value": expr::round_reported(shown), "references": refs})
ExprEvaluatedBody::Value {
ok: true,
value: expr::round_reported(v),
kind: EvalKind::from(k),
display_value: expr::round_reported(shown),
references: refs,
defines: defines.map(str::to_string),
}
}
Err(err) => json!({"ok": false, "error": err.to_string().trim_start_matches("expression: ").to_string(), "references": refs}),
Err(err) => ExprEvaluatedBody::Failed {
ok: false,
error: err.to_string().trim_start_matches("expression: ").to_string(),
references: refs,
defines: defines.map(str::to_string),
},
};
if let Some(name) = defines {
out["defines"] = json!(name);
}
Ok(out)
reply(cmd, ExprEvaluated { body, param_warnings: Vec::new() })
}
fn complete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.complete";
let text = str_(p, "prefix").unwrap_or("");
// The identifier being typed: trailing letters, digits and underscores.
let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::<Vec<_>>().into_iter().rev().collect();
@@ -565,7 +617,7 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
// start.
let contains = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
let within = |n: &str| contains(n) || solidcraft_doc::name_words_match(n, &word);
let mut out: Vec<Value> = Vec::new();
let mut out: Vec<Completion> = Vec::new();
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| within(&r.name)).collect();
// Names that start with the word, then names that contain it, then names matched only by
// the starts of their words; within each, favourites, then user parameters, then the others.
@@ -580,22 +632,22 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
if !r.comment.is_empty() {
detail.push_str(&format!(" — {}", r.comment));
}
out.push(json!({"text": r.name, "kind": "parameter", "detail": detail}));
out.push(Completion { text: r.name, kind: CompletionKind::Parameter, detail });
}
for (f, sig) in expr::FUNCTIONS {
if starts(f) {
out.push(json!({"text": format!("{f}("), "kind": "function", "detail": sig}));
out.push(Completion { text: format!("{f}("), kind: CompletionKind::Function, detail: sig.to_string() });
}
}
if starts("pi") {
out.push(json!({"text": "pi", "kind": "constant", "detail": "3.14159…"}));
out.push(Completion { text: "pi".into(), kind: CompletionKind::Constant, detail: "3.14159…".into() });
}
for (u, k, _) in expr::UNITS {
if !word.is_empty() && starts(u) {
out.push(json!({"text": u, "kind": "unit", "detail": k}));
out.push(Completion { text: u.to_string(), kind: CompletionKind::Unit, detail: EvalKind::from(*k).word().to_string() });
}
}
Ok(json!({"word": word, "suggestions": out}))
reply(cmd, ExprCompletions { word, suggestions: out, param_warnings: Vec::new() })
}
#[cfg(test)]
+1 -1
View File
@@ -1721,7 +1721,7 @@ fn cpu_saved_thin_sheet_uses_its_authored_ior_beer_thickness_and_roundtrips_offl
let a = (-sigma * 2.0).exp();
let denominator = 1.0 - f * f * a * a;
let scene = f + (1.0 - f).powi(2) * (f * a * a + a) / denominator;
solidcraft_geom::srgb::encode8(scene / (1.0 + scene))
solidcraft_color::srgb::encode8(scene / (1.0 + scene))
});
assert_eq!(decoded.get_pixel(0, 0).0, [rgb[0], rgb[1], rgb[2], 255]);
assert!(Arc::ptr_eq(&before, &s.doc));
+1 -1
View File
@@ -401,7 +401,7 @@ pub(super) fn apply_appearance(s: &mut Session, params: &Value) -> Result<Value>
let value = item("surfaces", &input.id, command)?;
let lo = triple(&value["base_linear_low"], command)?;
let hi = triple(&value["base_linear_high"], command)?;
let color = std::array::from_fn::<_, 3, _>(|i| crate::geom::srgb::encode8((lo[i] + hi[i]) * 0.5));
let color = std::array::from_fn::<_, 3, _>(|i| solidcraft_color::srgb::encode8((lo[i] + hi[i]) * 0.5));
(
value,
LibraryLook {
+38 -14
View File
@@ -11,15 +11,30 @@ use solidcraft_doc::{Document, Feature, FeatureKind, Param, expr};
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::schema::timeline_clipboard::{AddParams, AddResult, ExportScriptParams, ExportScriptResult, Script, ScriptAbout, ScriptStep};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const ADD_CONTRACT: TypedContract<AddParams> = TypedContract::new();
const EXPORT_SCRIPT_CONTRACT: TypedContract<ExportScriptParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.add", "Add Recorded Feature", add).icon("new").params(
CommandSpec::new("timeline.add", "Add Recorded Feature", crate::checked!("timeline.add", add))
.icon("new")
.contract(&ADD_CONTRACT)
.output(output_schema_for::<AddResult>)
.params(
"feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; the name is kept even when another \
feature has it, the id is kept and refused when another feature has it, so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \
dimensions use); the feature goes at the marker → feature, name, error, warning",
),
CommandSpec::new("timeline.export_script", "Export Command Script", export_script).icon("export").noundo().doc_only().params(
CommandSpec::new("timeline.export_script", "Export Command Script", crate::checked!("timeline.export_script", export_script))
.icon("export")
.noundo()
.doc_only()
.contract(&EXPORT_SCRIPT_CONTRACT)
.output(output_schema_for::<ExportScriptResult>)
.params(
"path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \
steps, omitted: what the script leaves out (appearances, folders, views, configurations, print-face assignments); components and assemblies \
are not supported yet",
@@ -56,7 +71,14 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
s.refresh();
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let r = s.model.result(id);
Ok(json!({"feature": id, "name": name, "error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone())}))
let reply = AddResult {
feature: id,
name,
error: r.and_then(|r| r.error.clone()),
warning: r.and_then(|r| r.warning.clone()),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// timeline.add keeps accepting legacy name-keyed pattern and mirror seeds and construction
@@ -139,7 +161,8 @@ fn recorded_feature(cmd: &str, raw: &Map<String, Value>) -> Result<Feature> {
Ok(f)
}
fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
/// The model parameters of a `timeline.add` step. The reader the command's contract uses too.
pub(crate) fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let list = v.as_array().filter(|a| a.len() <= MAX_STEP_PARAMS).ok_or_else(|| bad(cmd, "`params` must be a list of parameters"))?;
let text = |p: &Value, k: &str| match p.get(k) {
@@ -164,15 +187,15 @@ fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.export_script";
let (commands, omitted) = script_steps(&s.doc)?;
let steps = commands.len();
let script = json!({
"about": {
"design": s.doc.name,
"format": solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
"version": env!("CARGO_PKG_VERSION"),
"replay": "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)",
let script = Script {
about: ScriptAbout {
design: s.doc.name.clone(),
format: solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
replay: "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
"commands": commands,
});
commands,
};
let path = str_(p, "path");
if let Some(path) = path {
let text = serde_json::to_string_pretty(&script).map_err(|e| EngineError::Other(e.to_string()))?;
@@ -180,11 +203,12 @@ fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
} else if p.get("path").is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, "`path` must be a file path"));
}
Ok(json!({"script": script, "steps": steps, "omitted": omitted, "path": path}))
let path = path.map(str::to_string);
serde_json::to_value(ExportScriptResult { omitted, path, script, steps, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
}
fn step(command: &str, params: Value) -> Value {
json!({"command": command, "params": params})
serde_json::to_value(ScriptStep { command: command.to_string(), params }).unwrap_or_default()
}
fn not_supported(what: &str) -> EngineError {
+110 -51
View File
@@ -16,8 +16,10 @@ use super::features::{add_feature, check_expr, feature_sketch, profiles, sketch_
use crate::params::{bad, bool_, expr, expr_problem, req_expr, str_, string_list, vec3};
use crate::schema::features::FeatureResult;
use crate::schema::sheet::{
ConvertParams, FlangeParams, FoldParams, HemParams, ListedRule, ManageRulesParams, ManageRulesResult, PerforateParams, RuleFeatureError,
RuleReply, RulesParams, RulesResult, UnfoldParams,
BendDirection, BendTableFormat, BendTableParams, BendTableResult, ConvertParams, FlangeParams, FlatBend, FlatBore, FlatCircle, FlatPatternParams,
FlatPatternResult, FlatPerforation, FlatPerforationPattern, FlatPocket, FlatSegment, FlatShape, FoldParams, HemParams, ListedRule,
ManageRulesParams, ManageRulesResult, PathFlangeParams, PerforateParams, PocketSide, RuleFeatureError, RuleReply, RulesParams, RulesResult,
UnfoldParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -32,6 +34,10 @@ const CONVERT_CONTRACT: TypedContract<ConvertParams> = TypedContract::new();
const RULES_CONTRACT: TypedContract<RulesParams> = TypedContract::new();
const MANAGE_RULES_CONTRACT: TypedContract<ManageRulesParams> = TypedContract::new();
const PATH_FLANGE_CONTRACT: TypedContract<PathFlangeParams> = TypedContract::new();
const FLAT_PATTERN_CONTRACT: TypedContract<FlatPatternParams> = TypedContract::new();
const BEND_TABLE_CONTRACT: TypedContract<BendTableParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.flange", "Flange", crate::checked!("sheet.flange", flange))
.at("SHEET METAL", "CREATE")
@@ -53,7 +59,12 @@ pub static COMMANDS: &[CommandSpec] = &[
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused, \
and so is the corner relief on a base or contour flange",
),
CommandSpec::new("sheet.path_flange", "Path Flange", path_flange).at("SHEET METAL", "CREATE").icon("path_flange").params(
CommandSpec::new("sheet.path_flange", "Path Flange", crate::checked!("sheet.path_flange", path_flange))
.at("SHEET METAL", "CREATE")
.icon("path_flange")
.contract(&PATH_FLANGE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"edge: [x,y,z] on a top or bottom edge of the sheet; sketch + curves: an open chain of sketch lines drawn on a plane across \
that edge, from the edge out (each line one flange as long as the line, to the outer face from the virtual sharp; each \
corner one bend, turning the way the lines turn); radius? (default: rule); body?. One feature, one body",
@@ -121,10 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", crate::checked!("sheet.rules", list_rules))
.contract(&RULES_CONTRACT).output(output_schema_for::<RulesResult>).icon("sheet_rules").noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", crate::checked!("sheet.flat_pattern", flat_pattern))
.at("SHEET METAL", "CREATE")
.icon("flat")
.noundo()
.contract(&FLAT_PATTERN_CONTRACT)
.output(output_schema_for::<FlatPatternResult>)
.params(
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
),
@@ -141,8 +154,10 @@ pub static COMMANDS: &[CommandSpec] = &[
→ DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \
POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM (one per side and depth) and PERFORATION; inner contours come before the outline",
),
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table).icon("bendtable")
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", crate::checked!("sheet.export_bend_table", export_bend_table)).icon("bendtable")
.noundo()
.contract(&BEND_TABLE_CONTRACT)
.output(output_schema_for::<BendTableResult>)
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; angle_deg is the bend angle and included_angle_deg the angle left between the bend's sides (180° less it); a bend that cut-outs split has a row for each piece"),
];
@@ -734,24 +749,24 @@ fn loop_circle(lp: &solidcore_record::Loop2) -> Option<(Vec2, f64)> {
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
fn loop_shape(lp: &solidcore_record::Loop2) -> Value {
fn loop_shape(lp: &solidcore_record::Loop2) -> FlatShape {
use solidcore_record::Seg2;
let pt = |v: Vec2| json!([v.x, v.y]);
let pt = |v: Vec2| [v.x, v.y];
if let Some((center, radius)) = loop_circle(lp) {
return json!({"circle": {"center": pt(center), "radius": radius}});
return FlatShape::Circle { circle: FlatCircle { center: pt(center), radius } };
}
let segments: Vec<Value> = lp
let segments: Vec<FlatSegment> = lp
.segs
.iter()
.map(|seg| match *seg {
Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}),
Seg2::Line { a, b } => FlatSegment::Line([pt(a), pt(b)]),
Seg2::Arc { center, radius, start, sweep } => {
json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}})
FlatSegment::Arc { center: pt(center), radius, start_deg: start.to_degrees(), sweep_deg: sweep.to_degrees() }
}
ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::<Vec<_>>()}),
ref other => FlatSegment::Polyline(other.polyline(1e-3).into_iter().map(pt).collect()),
})
.collect();
json!({"segments": segments})
FlatShape::Segments { segments }
}
fn flat_json(sh: &SheetBody) -> Result<Value> {
@@ -759,63 +774,98 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
let loops: Vec<Ring<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
let size = loops.iter().fold(Aabb2::EMPTY, |b, l| b.union(&l.bounds())).size();
let (w, h) = (size.x, size.y);
let pt = |v: &Vec2| json!([v.x, v.y]);
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
let bends: Vec<Value> = sh
let pt = |v: &Vec2| [v.x, v.y];
let outline: Vec<Vec<[f64; 2]>> = loops.iter().map(|l| l.iter().map(pt).collect()).collect();
let holes: Vec<Vec<[f64; 2]>> = regions.iter().flat_map(|r| &r.holes).map(|h| h.polyline(1e-3).iter().map(pt).collect()).collect();
let hole_shapes: Vec<FlatShape> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
let bends: Vec<FlatBend> = sh
.bends()
.iter()
.map(|b| {
json!({"bend": b.index, "angle_deg": b.angle_deg, "included_angle_deg": b.included_angle_deg(), "radius": b.radius, "direction": if b.up { "up" } else { "down" },
"allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)})
.map(|b| FlatBend {
bend: b.index,
angle_deg: b.angle_deg,
included_angle_deg: b.included_angle_deg(),
radius: b.radius,
direction: if b.up { BendDirection::Up } else { BendDirection::Down },
allowance: b.allowance,
length: b.length,
start: pt(&b.start),
end: pt(&b.end),
})
.collect();
let pockets: Vec<Value> = sh
let pockets: Vec<FlatPocket> = sh
.machining()?
.iter()
.map(|p| {
let side = if p.z0 == 0.0 {
"bottom"
PocketSide::Bottom
} else if p.z1 == sh.t {
"top"
PocketSide::Top
} else {
"internal"
PocketSide::Internal
};
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
FlatPocket {
outline: p.region.outer.polyline(1e-3).iter().map(pt).collect(),
cutouts: p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect()).collect(),
cutout_shapes: p.region.holes.iter().map(loop_shape).collect(),
side,
depth_mm: p.z1 - p.z0,
z0_mm: p.z0,
z1_mm: p.z1,
}
})
.collect();
// Holes drilled along a panel into its rim: drilled after cutting, not in the outline.
let bores: Vec<Value> = sh
let bores: Vec<FlatBore> = sh
.bores
.iter()
.map(|b| json!({"start": [b.base.x, b.base.y, b.base.z], "axis": [b.axis.x, b.axis.y, b.axis.z], "diameter": b.radius * 2.0, "length": b.height.abs()}))
.map(|b| FlatBore {
start: [b.base.x, b.base.y, b.base.z],
axis: [b.axis.x, b.axis.y, b.axis.z],
diameter: b.radius * 2.0,
length: b.height.abs(),
})
.collect();
let mut perforations = Vec::new();
for p in &sh.perforations {
let n = sh.perforation_holes(p)?.len();
perforations.push(json!({"feature": p.feature, "diameter": p.diameter, "pitch": p.pitch, "margin": p.margin,
"pattern": if p.square { "square" } else { "staggered" }, "count": n,
"open_area_mm2": n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2)}));
perforations.push(FlatPerforation {
feature: p.feature,
diameter: p.diameter,
pitch: p.pitch,
margin: p.margin,
pattern: if p.square { FlatPerforationPattern::Square } else { FlatPerforationPattern::Staggered },
count: n,
open_area_mm2: n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2),
});
}
let bend_zones: Vec<Value> = sh
let bend_zones: Vec<[f64; 4]> = sh
.bend_zones()
.iter()
.map(|z| {
let pts = z.outer.polyline(1e-3);
let f = |g: fn(&Vec2) -> f64, m: fn(f64, f64) -> f64, s: f64| pts.iter().map(g).fold(s, m);
json!([f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)])
[f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)]
})
.collect();
let mut out = json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat,
"perforations": perforations, "bend_zones": bend_zones});
if !bores.is_empty() {
out["bores"] = Value::Array(bores);
}
Ok(out)
let out = FlatPatternResult {
body: sh.body.clone(),
rule: sh.rule.clone(),
thickness: sh.t,
k_factor: sh.k,
flat_size_mm: [w, h, sh.t],
outline,
cutouts: holes,
cutout_shapes: hole_shapes,
pockets,
bends,
unfolded: sh.flat,
perforations,
bend_zones,
bores,
param_warnings: Vec::new(),
};
serde_json::to_value(out).map_err(|e| EngineError::Other(e.to_string()))
}
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1137,16 +1187,22 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
}
/// A bend table's `format`: csv or json, in any case.
pub(crate) fn bend_table_format(t: &str) -> Option<BendTableFormat> {
match t.to_ascii_lowercase().as_str() {
"csv" => Some(BendTableFormat::Csv),
"json" => Some(BendTableFormat::Json),
_ => None,
}
}
fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.export_bend_table";
let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase();
if !matches!(format.as_str(), "csv" | "json") {
return Err(bad(cmd, "`format` must be csv or json"));
}
let format = str_(p, "format").map_or(Some(BendTableFormat::Csv), bend_table_format).ok_or_else(|| bad(cmd, "`format` must be csv or json"))?;
let sh = sheet_of(s, p, cmd)?;
sh.flat_matches_body()?;
let table = flat_json(&sh)?;
let text = if format == "json" {
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.
@@ -1176,11 +1232,14 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
}
csv
};
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
let bends = sh.bends().len();
let reply = if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()}));
}
Ok(json!({"format": format, "text": text, "bends": sh.bends().len()}))
BendTableResult::Written { path: path.to_string(), format, bends, param_warnings: Vec::new() }
} else {
BendTableResult::Text { format, text, bends, param_warnings: Vec::new() }
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
#[cfg(test)]
File diff suppressed because it is too large Load Diff
+35 -11
View File
@@ -11,16 +11,30 @@ use super::sketch::edit;
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, req_vec3, vec3};
use crate::schema::sketch_3d::{Curves3dResult, Line3dParams, Move3dParams, Move3dResult, Point3dParams, Point3dResult, Spline3dParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const LINE3D_CONTRACT: TypedContract<Line3dParams> = TypedContract::new();
const SPLINE3D_CONTRACT: TypedContract<Spline3dParams> = TypedContract::new();
const POINT3D_CONTRACT: TypedContract<Point3dParams> = TypedContract::new();
const MOVE3D_CONTRACT: TypedContract<Move3dParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch_3d).params(concat!(
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
created_doc!()
)),
CommandSpec::new("sketch.spline3d", "3D Spline", spline3d)
CommandSpec::new("sketch.line3d", "3D Line", crate::checked!("sketch.line3d", line3d))
.icon("line")
.enabled(in_sketch_3d)
.contract(&LINE3D_CONTRACT)
.output(output_schema_for::<Curves3dResult>)
.params(concat!(
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
created_doc!()
)),
CommandSpec::new("sketch.spline3d", "3D Spline", crate::checked!("sketch.spline3d", spline3d))
.icon("spline")
.enabled(in_sketch_3d)
.contract(&SPLINE3D_CONTRACT)
.output(output_schema_for::<Curves3dResult>)
.params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.arc3d", "3D Arc", arc3d)
.icon("arc3")
@@ -29,16 +43,26 @@ pub static COMMANDS: &[CommandSpec] = &[
"start, through, end: [x,y,z] world points: an exact circular arc through them, from start through through to end (needs 3D Sketch on). Stored exactly (centre, radius, angles), so path consumers can treat it as a true arc",
created_doc!()
)),
CommandSpec::new("sketch.point3d", "3D Point", point3d)
CommandSpec::new("sketch.point3d", "3D Point", crate::checked!("sketch.point3d", point3d))
.icon("point")
.enabled(in_sketch_3d)
.contract(&POINT3D_CONTRACT)
.output(output_schema_for::<Point3dResult>)
.params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.move3d", "Move 3D Point", move3d)
CommandSpec::new("sketch.move3d", "Move 3D Point", crate::checked!("sketch.move3d", move3d))
.icon("sketch_move")
.enabled(in_sketch)
.contract(&MOVE3D_CONTRACT)
.output(output_schema_for::<Move3dResult>)
.params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"),
];
/// A command's reply as JSON: built from the type its output schema describes, so the two can't
/// drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
fn points(cmd: &str, p: &Value, min: usize) -> Result<Vec<Vec3>> {
let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must list [x,y,z] points"))?;
if a.len() < min || a.len() > solidcraft_sketch::MAX_FIT_POINTS {
@@ -106,7 +130,7 @@ fn line3d(s: &mut Session, p: &Value) -> Result<Value> {
needs_3d(sk, cmd)?;
pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::<Result<Vec<String>>>()
})?;
Ok(json!({"curves": ids, "sketch": info}))
reply(cmd, Curves3dResult { curves: ids, sketch: info, param_warnings: Vec::new() })
}
fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
@@ -117,7 +141,7 @@ fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
needs_3d(sk, cmd)?;
Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?)
})?;
Ok(json!({"curves": [id], "sketch": info}))
reply(cmd, Curves3dResult { curves: vec![id], sketch: info, param_warnings: Vec::new() })
}
fn arc3d(s: &mut Session, p: &Value) -> Result<Value> {
@@ -145,7 +169,7 @@ fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
needs_3d(sk, cmd)?;
Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?)
})?;
Ok(json!({"point": id, "sketch": info}))
reply(cmd, Point3dResult { point: id, sketch: info, param_warnings: Vec::new() })
}
fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
@@ -183,7 +207,7 @@ fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
};
Ok(sk.move_drawn_point(&wire, index, to)?)
})?;
Ok(json!({"moved": n, "sketch": info}))
reply(cmd, Move3dResult { moved: n, sketch: info, param_warnings: Vec::new() })
}
#[cfg(test)]
@@ -95,6 +95,23 @@ fn a_real_curve_named_like_an_axis_wins() {
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
}
/// Tangent also takes a curve's end point (#12092), but `x` and `y` still name the axes before
/// their lines exist: a point called `x` does not take the axis's place.
#[test]
fn a_point_named_like_an_axis_leaves_tangent_on_the_axis() {
for (a, b) in [("c", "x"), ("x", "c")] {
let mut s = on(json!("XY"));
run(&mut s, "sketch.point", json!({"point": [3, 7], "id": "x", "connect": false}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
let sk = sketch(&s);
assert_eq!(axis_lines(&sk).iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["x"], "{a}, {b}: the x axis's line");
let CurveKind::Circle { c: centre, r } = sk.curves[sk.curve_index("c").unwrap()].kind else { panic!() };
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "{a}, {b}: tangent to x");
assert!(pt(&sk, "x").dist(Vec2::new(3.0, 7.0)) < 1e-9, "{a}, {b}: the point called x moved");
}
}
#[test]
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
let mut s = on(json!("XY"));
+108 -40
View File
@@ -1,54 +1,81 @@
//! More sketch constraints (curvature, polygon), constraint glyph anchors and snapping
//! inference for interactive tools.
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::Vec2;
use solidcraft_geom::prelude::*;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
use super::sketch::{add_c, dof_now, edit, keep_redundant, listed, redundancy, residual_of};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::schema::sketch_constraints::{ToggleDiameterParams, ToggleDiameterResult};
use crate::schema::sketch_constraints::{
AutoConstrainDatumParams, AutoConstrainParams, AutoConstrainResult, ConstrainerParams, ConstrainerResult, ConstraintAdded, CurvatureParams,
FinishAutoConstrainParams, Glyph, GlyphsParams, GlyphsResult, PolygonParams, PolygonResult, Snap, SnapParams, SnapResult, ToggleDiameterParams,
ToggleDiameterResult, ToggleDrivenParams, ToggleDrivenResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const TOGGLE_DIAMETER_CONTRACT: TypedContract<ToggleDiameterParams> = TypedContract::new();
const CURVATURE_CONTRACT: TypedContract<CurvatureParams> = TypedContract::new();
const POLYGON_CONTRACT: TypedContract<PolygonParams> = TypedContract::new();
const AUTO_CONSTRAIN_CONTRACT: TypedContract<AutoConstrainParams> = TypedContract::new();
const AUTO_CONSTRAIN_DATUM_CONTRACT: TypedContract<AutoConstrainDatumParams> = TypedContract::new();
const FINISH_AUTO_CONSTRAIN_CONTRACT: TypedContract<FinishAutoConstrainParams> = TypedContract::new();
const CONSTRAINER_CONTRACT: TypedContract<ConstrainerParams> = TypedContract::new();
const TOGGLE_DRIVEN_CONTRACT: TypedContract<ToggleDrivenParams> = TypedContract::new();
const GLYPHS_CONTRACT: TypedContract<GlyphsParams> = TypedContract::new();
const SNAP_CONTRACT: TypedContract<SnapParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.constraint.curvature", "Curvature", c_smooth)
CommandSpec::new("sketch.constraint.curvature", "Curvature", crate::checked!("sketch.constraint.curvature", c_smooth))
.at("SKETCH", "CONSTRAINTS")
.icon("c_smooth")
.enabled(in_sketch)
.contract(&CURVATURE_CONTRACT)
.output(output_schema_for::<ConstraintAdded>)
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
CommandSpec::new("sketch.constraint.polygon", "Polygon", c_polygon)
CommandSpec::new("sketch.constraint.polygon", "Polygon", crate::checked!("sketch.constraint.polygon", c_polygon))
.at("SKETCH", "CONSTRAINTS")
.icon("c_polygon")
.enabled(in_sketch)
.contract(&POLYGON_CONTRACT)
.output(output_schema_for::<PolygonResult>)
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", auto_constrain)
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", crate::checked!("sketch.auto_constrain", auto_constrain))
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.contract(&AUTO_CONSTRAIN_CONTRACT)
.output(output_schema_for::<AutoConstrainResult>)
.params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"),
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from Datum", auto_constrain_datum)
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from Datum", crate::checked!("sketch.auto_constrain_from_datum", auto_constrain_datum))
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.contract(&AUTO_CONSTRAIN_DATUM_CONTRACT)
.output(output_schema_for::<AutoConstrainResult>)
.params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"),
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", auto_constrain_finish)
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", crate::checked!("sketch.finish_auto_constrain", auto_constrain_finish))
.at("SKETCH", "FINISH SKETCH")
.icon("finish")
.enabled(in_sketch)
.contract(&FINISH_AUTO_CONSTRAIN_CONTRACT)
.output(output_schema_for::<AutoConstrainResult>)
.params("like AutoConstrain, then Finish Sketch"),
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", smart_constrain)
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", crate::checked!("sketch.constrainer", smart_constrain))
.at("SKETCH", "CREATE")
.icon("constrainer")
.enabled(in_sketch)
.contract(&CONSTRAINER_CONTRACT)
.output(output_schema_for::<ConstrainerResult>)
.params("entities: [one or two sketch entity ids]: applies the constraint the geometry nearly has (horizontal/vertical, parallel, perpendicular, collinear, coincident, concentric, equal, tangent)"),
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven).icon("dimension")
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", crate::checked!("sketch.toggle_driven", toggle_driven)).icon("dimension")
.enabled(in_sketch)
.contract(&TOGGLE_DRIVEN_CONTRACT)
.output(output_schema_for::<ToggleDrivenResult>)
.params("constraint: dimension id, driven?: bool (default toggles); a driving dimension gets a parameter with its current value"),
CommandSpec::new("sketch.toggle_diameter", "Toggle Radius / Diameter", crate::checked!("sketch.toggle_diameter", toggle_diameter))
.icon("dimension")
@@ -56,13 +83,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&TOGGLE_DIAMETER_CONTRACT)
.output(output_schema_for::<ToggleDiameterResult>)
.params("constraint: a circle's or an arc's radius or diameter dimension id; diameter?: bool (default: the other one); sketch?: id|name (default: the one being edited). The circle stays the same size: its parameter is halved or doubled (a plain value is rewritten, an expression becomes `(e) / 2` or `2 * (e)`); refused when anything else uses that parameter"),
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs).icon("eye")
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", crate::checked!("sketch.glyphs", glyphs)).icon("eye")
.enabled(in_sketch)
.noundo()
.contract(&GLYPHS_CONTRACT)
.output(output_schema_for::<GlyphsResult>)
.params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"),
CommandSpec::new("sketch.snap", "Snap Inference", snap).icon("snap")
CommandSpec::new("sketch.snap", "Snap Inference", crate::checked!("sketch.snap", snap)).icon("snap")
.enabled(in_sketch)
.noundo()
.contract(&SNAP_CONTRACT)
.output(output_schema_for::<SnapResult>)
.params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"),
];
@@ -78,7 +109,26 @@ fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
Ok(id)
})?;
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, Value::Null)
let (redundant, determined_by) = redundant_part(p, cmd, by)?;
reply(cmd, ConstraintAdded { constraint: id, sketch: info, redundant, determined_by, created: None, param_warnings: Vec::new() })
}
/// A command's reply as JSON: they build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// What a new constraint's reply says of redundancy: whether it is, and by which constraints. A
/// redundant one is refused when the call asked for `if_redundant: "refuse"`.
fn redundant_part(p: &Value, cmd: &str, by: Option<Vec<String>>) -> Result<(bool, Option<Vec<String>>)> {
match by {
Some(by) => {
keep_redundant(p, cmd, &by)?;
Ok((true, Some(by)))
}
None => Ok((false, None)),
}
}
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
@@ -126,7 +176,7 @@ fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(cons)
})?;
Ok(json!({"constraints": out, "sketch": info}))
reply(cmd, PolygonResult { constraints: out, sketch: info, created: None, param_warnings: Vec::new() })
}
/// Most constraints AutoConstrain tries.
@@ -273,7 +323,7 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
// its freedom whenever they change constraints; no separate initial solve is needed.
Ok(run_auto(sk, doc, p, datum, rep.dof))
})?;
Ok(json!({"added": added, "dof": dof, "sketch": info}))
reply(cmd, AutoConstrainResult { added, dof, sketch: info, created: None, param_warnings: Vec::new() })
}
fn auto_constrain(s: &mut Session, p: &Value) -> Result<Value> {
@@ -375,7 +425,19 @@ fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
Ok(id)
})?;
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, json!({"type": name}))
let (redundant, determined_by) = redundant_part(p, cmd, by)?;
reply(
cmd,
ConstrainerResult {
constraint: id,
sketch: info,
redundant,
determined_by,
kind: name.to_string(),
created: None,
param_warnings: Vec::new(),
},
)
}
fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
@@ -417,7 +479,7 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
}
}
s.doc_mut().prune_model_params();
Ok(json!({"constraint": cid, "driven": driven, "sketch": info}))
reply(cmd, ToggleDrivenResult { constraint: cid, driven, sketch: info, created: None, param_warnings: Vec::new() })
}
fn toggle_diameter(s: &mut Session, p: &Value) -> Result<Value> {
@@ -528,26 +590,32 @@ fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
let failing = &ss.report.failing;
let list: Vec<Value> = ss
let glyphs: Vec<Glyph> = ss
.sketch
.constraints
.iter()
.map(|c| {
json!({
"id": c.id,
"name": c.kind.name(),
"dimension": c.kind.is_dimension(),
"driven": c.driven,
"value": c.kind.value(),
"param": c.param,
"at": anchor(&ss.sketch, &c.kind),
"failing": failing.contains(&c.id),
// Implied by the others (it removes no freedom) but holding: shown dimmed.
"redundant": ss.report.redundant.contains(&c.id),
})
.map(|c| Glyph {
id: c.id.clone(),
name: c.kind.name().to_string(),
dimension: c.kind.is_dimension(),
driven: c.driven,
value: c.kind.value(),
param: c.param.clone(),
at: anchor(&ss.sketch, &c.kind).map(|a| [a.x, a.y]),
failing: failing.contains(&c.id),
// Implied by the others (it removes no freedom) but holding: shown dimmed.
redundant: ss.report.redundant.contains(&c.id),
})
.collect();
Ok(json!({"sketch": id, "glyphs": list, "dof": ss.report.dof, "fully_constrained": ss.report.fully_constrained(), "conflicts": failing}))
let result = GlyphsResult {
sketch: id,
glyphs,
dof: ss.report.dof,
fully_constrained: ss.report.fully_constrained(),
conflicts: failing.clone(),
param_warnings: Vec::new(),
};
reply("sketch.glyphs", result)
}
/// Snap inference for a click at `at`: the nearest sketch point (coincident), the midpoint of a
@@ -562,12 +630,12 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
let sk = &ss.sketch;
let mut out: Vec<(f64, Value)> = Vec::new();
let mut out: Vec<(f64, Snap)> = Vec::new();
// Points (end points, centres, the origin).
for q in &sk.points {
let d = q.pos.dist(at);
if d <= r {
out.push((d * 0.5, json!({"type": "coincident", "point": q.id, "at": q.pos})));
out.push((d * 0.5, Snap::Coincident { point: q.id.clone(), at: [q.pos.x, q.pos.y] }));
}
}
for (i, c) in sk.curves.iter().enumerate() {
@@ -577,13 +645,13 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
let m = (pa + pb) * 0.5;
let d = m.dist(at);
if d <= r {
out.push((d * 0.6, json!({"type": "midpoint", "curve": c.id, "at": m})));
out.push((d * 0.6, Snap::Midpoint { curve: c.id.clone(), at: [m.x, m.y] }));
}
}
if let Some(sh) = sk.shape(i) {
let q = sh.project(at);
if sh.dist(at) <= r && q.dist(at) <= r {
out.push((sh.dist(at) + r * 0.5, json!({"type": "on_curve", "curve": c.id, "at": q})));
out.push((sh.dist(at) + r * 0.5, Snap::OnCurve { curve: c.id.clone(), at: [q.x, q.y] }));
}
}
}
@@ -591,15 +659,15 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = from {
let d = at - f;
if d.y.abs() <= r && d.x.abs() > r {
out.push((d.y.abs() + r, json!({"type": "horizontal", "at": [at.x, f.y]})));
out.push((d.y.abs() + r, Snap::Horizontal { at: [at.x, f.y] }));
}
if d.x.abs() <= r && d.y.abs() > r {
out.push((d.x.abs() + r, json!({"type": "vertical", "at": [f.x, at.y]})));
out.push((d.x.abs() + r, Snap::Vertical { at: [f.x, at.y] }));
}
}
out.sort_by(|a, b| a.0.total_cmp(&b.0));
let list: Vec<Value> = out.into_iter().take(8).map(|x| x.1).collect();
Ok(json!({"snaps": list}))
let snaps: Vec<Snap> = out.into_iter().take(8).map(|x| x.1).collect();
reply(cmd, SnapResult { snaps, param_warnings: Vec::new() })
}
#[cfg(test)]
+142 -62
View File
@@ -13,11 +13,16 @@ macro_rules! text_style_doc {
};
}
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot};
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, slot_parts};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list};
use crate::schema::sketch_create::{SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams, SplineReverseResult};
use crate::schema::sketch_create::{
ArcSlotCenterParams, ArcSlotResult, ArcSlotThreePointParams, ArcTangentParams, BlendCurveParams, CenterlineParams, ConicParams,
ControlPointSplineParams, DrawResult, EditTextParams, EditTextResult, EllipseParams, FitPointSplineParams, InfiniteLineParams, LineTypeResult,
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams,
SplineReverseResult, TextChange, TextEditsResult, TextExpressions, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -29,62 +34,108 @@ mod text_move_tests;
#[path = "sketch_text_expr_tests.rs"]
mod text_expr_tests;
const MIDPOINT_LINE_CONTRACT: TypedContract<MidpointLineParams> = TypedContract::new();
const ARC_TANGENT_CONTRACT: TypedContract<ArcTangentParams> = TypedContract::new();
const TWO_TANGENT_CONTRACT: TypedContract<TwoTangentCircleParams> = TypedContract::new();
const THREE_TANGENT_CONTRACT: TypedContract<ThreeTangentCircleParams> = TypedContract::new();
const SLOT_CENTER_POINT_CONTRACT: TypedContract<SlotCenterPointParams> = TypedContract::new();
const ARC_SLOT_THREE_CONTRACT: TypedContract<ArcSlotThreePointParams> = TypedContract::new();
const ARC_SLOT_CENTER_CONTRACT: TypedContract<ArcSlotCenterParams> = TypedContract::new();
const ELLIPSE_CONTRACT: TypedContract<EllipseParams> = TypedContract::new();
const SPLINE_FIT_CONTRACT: TypedContract<FitPointSplineParams> = TypedContract::new();
const SPLINE_CONTROL_CONTRACT: TypedContract<ControlPointSplineParams> = TypedContract::new();
const CONIC_CONTRACT: TypedContract<ConicParams> = TypedContract::new();
const BLEND_CONTRACT: TypedContract<BlendCurveParams> = TypedContract::new();
const CENTERLINE_CONTRACT: TypedContract<CenterlineParams> = TypedContract::new();
const INFINITE_CONTRACT: TypedContract<InfiniteLineParams> = TypedContract::new();
const TEXT_CONTRACT: TypedContract<SketchTextParams> = TypedContract::new();
const EDIT_TEXT_CONTRACT: TypedContract<EditTextParams> = TypedContract::new();
const SPLINE_REVERSE_CONTRACT: TypedContract<SplineReverseParams> = TypedContract::new();
const SPLINE_REMOVE_POINT_CONTRACT: TypedContract<SplineRemovePointParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", crate::checked!("sketch.line.midpoint", midpoint_line))
.at("SKETCH", "CREATE")
.icon("line_mid")
.enabled(in_sketch)
.contract(&MIDPOINT_LINE_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("mid: [x,y], point ref or snap (\"mid:<line>\", \"on:<curve>:x,y\", model vertex/edge); end: [x,y]; construction?", created_doc!())),
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", crate::checked!("sketch.arc.tangent", arc_tangent))
.at("SKETCH", "CREATE")
.icon("arc_tangent")
.enabled(in_sketch)
.contract(&ARC_TANGENT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!(
"start: end point of a line or arc (\"l1.end\"), end: [x,y] or an existing point's id (\"l2.start\": the arc ends on that point); curve?: the curve to be tangent to (default: the one ending at start)",
created_doc!()
)),
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", crate::checked!("sketch.circle.two_tangent", circle_tan_tan))
.at("SKETCH", "CREATE")
.icon("circle_tt")
.enabled(in_sketch)
.contract(&TWO_TANGENT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", crate::checked!("sketch.circle.three_tangent", circle_three_tan))
.at("SKETCH", "CREATE")
.icon("circle_ttt")
.enabled(in_sketch)
.contract(&THREE_TANGENT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", crate::checked!("sketch.slot.center_point", slot_center_point))
.at("SKETCH", "CREATE")
.icon("slot_center")
.enabled(in_sketch)
.contract(&SLOT_CENTER_POINT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("center: slot centre, end: one arc centre, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", crate::checked!("sketch.slot.arc_three_point", arc_slot_three))
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.contract(&ARC_SLOT_THREE_CONTRACT)
.output(output_schema_for::<ArcSlotResult>)
.params(concat!("start, through, end: points on the centre arc, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", crate::checked!("sketch.slot.arc_center", arc_slot_center))
.at("SKETCH", "CREATE")
.icon("arc_slot_center")
.enabled(in_sketch)
.contract(&ARC_SLOT_CENTER_CONTRACT)
.output(output_schema_for::<ArcSlotResult>)
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise) or sweep: degrees (0 < sweep < 360), width; returns centre_arc", created_doc!())),
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.ellipse", "Ellipse", crate::checked!("sketch.ellipse", ellipse))
.at("SKETCH", "CREATE")
.icon("ellipse")
.enabled(in_sketch)
.contract(&ELLIPSE_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!(
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius. Also adds its major and minor axes as construction lines through the centre (their ends: the major end and the point opposite it, and the two ends of the minor axis on the ellipse), held by midpoint, perpendicular and point-on-curve constraints, so a half-axis is the distance from the centre to an axis end and an axis line's length is a full axis; axes?: bool (default true; false: the ellipse alone, as the Ellipse tool draws it with Draw ellipses with their axes off)",
created_doc!()
)),
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", crate::checked!("sketch.spline.fit_point", spline_fit))
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.contract(&SPLINE_FIT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", crate::checked!("sketch.spline.control_point", spline_cv3))
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.contract(&SPLINE_CONTROL_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: control points (2…500), cubic", created_doc!())),
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", crate::checked!("sketch.spline.control_point_5", spline_cv5))
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.contract(&SPLINE_CONTROL_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: control points (2…500), degree 5", created_doc!())),
CommandSpec::new("sketch.spline.insert_point", "Add Spline Point", spline_insert_point)
.icon("spline")
@@ -102,36 +153,54 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&SPLINE_REVERSE_CONTRACT)
.output(output_schema_for::<SplineReverseResult>)
.params("curve: spline id (fit point, control point or closed): its last point becomes its first and the curve runs the other way along the same path; its points and the constraints on them stay. Refused for a projected spline and for one an offset uses or makes. Returns curve"),
CommandSpec::new("sketch.conic", "Conic Curve", conic)
CommandSpec::new("sketch.conic", "Conic Curve", crate::checked!("sketch.conic", conic))
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.contract(&CONIC_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())),
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
CommandSpec::new("sketch.blend_curve", "Blend Curve", crate::checked!("sketch.blend_curve", blend))
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
.contract(&BLEND_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!(
"a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)",
created_doc!()
)),
CommandSpec::new("sketch.centerline", "Centerline", centerline)
CommandSpec::new("sketch.centerline", "Centerline", crate::checked!("sketch.centerline", centerline))
.at("SKETCH", "CREATE")
.icon("centerline")
.enabled(in_sketch)
.contract(&CENTERLINE_CONTRACT)
.output(output_schema_for::<LineTypeResult>)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line).icon("infinite")
CommandSpec::new("sketch.infinite", "Infinite Line", crate::checked!("sketch.infinite", infinite_line))
.icon("infinite")
.enabled(in_sketch)
.contract(&INFINITE_CONTRACT)
.output(output_schema_for::<LineTypeResult>)
.params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.text", "Text", crate::checked!("sketch.text", text))
.at("SKETCH", "CREATE")
.icon("sketch_text")
.enabled(in_sketch)
.contract(&TEXT_CONTRACT)
.output(output_schema_for::<TextResult>)
.params(concat!(
"text: string (\\n breaks lines), or text_expression: the words built from text parameters (parameters.add with unit text), number parameters and quoted texts joined by + (\"Label + '-L'\", \"'W' + Width\"; a number parameter shows its value in the design's units, \"85 mm\", an angle in degrees, \"30°\"; number(Width) is the number alone and number(Width, 2) has 2 decimals): the text keeps it, its words follow the parameters (a parameter edit or a configuration row relabels it) and `exprs` returns it as `text`; at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ",
text_style_doc!(),
created_doc!()
)),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text).icon("edit")
CommandSpec::new("sketch.edit_text", "Edit Text", crate::checked!("sketch.edit_text", edit_text))
.icon("edit")
.enabled(in_sketch)
.live(edit_text_live)
.contract(&EDIT_TEXT_CONTRACT)
.output(output_schema_for::<EditTextResult>)
.params(concat!(
"link: the text's link id (or entity: one of its curves), text? or text_expression? (as in sketch.text; typed words replace the expression), at?, height?, angle?: deg (numbers or expressions as in sketch.text; a number replaces an expression, and so does moving the text), font?: family or file path (empty: the built-in font), ",
text_style_doc!(),
@@ -141,6 +210,20 @@ pub static COMMANDS: &[CommandSpec] = &[
)),
];
/// A reply made of a typed value, which is also what its output schema describes.
fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
serde_json::to_value(value).map_err(|e| EngineError::Other(e.to_string()))
}
/// What a drawing command returns: the curves and constraints it made, and the sketch's solve.
fn drawn_result(out: (Vec<String>, Vec<String>), info: Value) -> DrawResult {
DrawResult { curves: out.0, constraints: out.1, sketch: info, created: None, param_warnings: Vec::new() }
}
fn drawn(out: (Vec<String>, Vec<String>), info: Value) -> Result<Value> {
reply(drawn_result(out, info))
}
/// The style a command's parameters give, over `old`.
fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
let mut style = old;
@@ -181,7 +264,7 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
}
/// What a text's `at` must be.
const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
pub(crate) const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
/// One number of a text, from a command's parameter `key`. A number is that number (mm, or
/// degrees for the angle). A string is an expression over the design's parameters: a bare number
@@ -278,15 +361,17 @@ fn text_source(doc: &solidcraft_doc::Document, p: &Value, cmd: &str, old: Option
}
/// The expressions behind a text's numbers, for a command's reply (`{"height": "plate_h / 10"}`).
fn text_exprs_json(src: &LinkSource) -> Option<Value> {
fn text_exprs(src: &LinkSource) -> Option<TextExpressions> {
let LinkSource::Text { exprs, .. } = src else { return None };
let mut out = serde_json::Map::new();
for (key, e) in [("x", &exprs.x), ("y", &exprs.y), ("height", &exprs.height), ("angle", &exprs.angle), ("text", &exprs.text)] {
if let Some(e) = e {
out.insert(key.into(), json!(e));
}
}
(!out.is_empty()).then_some(Value::Object(out))
let out = TextExpressions {
x: exprs.x.clone(),
y: exprs.y.clone(),
height: exprs.height.clone(),
angle: exprs.angle.clone(),
text: exprs.text.clone(),
};
let any = [&out.x, &out.y, &out.height, &out.angle, &out.text].iter().any(|e| e.is_some());
any.then_some(out)
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -296,7 +381,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
}
let src = text_source(&s.doc, p, cmd, None)?;
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let exprs = text_exprs_json(&src);
let exprs = text_exprs(&src);
let (l, curves, guide) = sketch_store::update(s, id, |_, _, sk| {
// A text needs nothing outside its sketch (a path is one of its curves).
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
@@ -308,11 +393,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
let curves = ids_of(sk, &sk.link_curves(&l));
Ok((l, curves, guide))
})?;
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
}
Ok(out)
reply(TextResult { change: TextChange { link: l, curves, guide, exprs }, created: None, param_warnings: Vec::new() })
}
/// A text's path is its guide: the curve it follows becomes construction. Left as it was, it
@@ -378,17 +459,18 @@ pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation:
}
/// The most texts one `sketch.edit_text {edits}` changes.
const MAX_TEXT_EDITS: usize = 1000;
pub(crate) const MAX_TEXT_EDITS: usize = 1000;
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.edit_text";
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
sketch_store::update(s, id, |_, doc, sk| {
let edited = sketch_store::update(s, id, |_, doc, sk| {
// The numbers its expressions give now (a parameter may have changed since the sketch was
// last stored); one that cannot be evaluated is left as it was, to be replaced by this edit.
let _ = doc.apply_text_values(&doc.param_values().0, sk);
apply_text_edits(sk, doc, p)
})
})?;
reply(edited)
}
/// One frame of an interactive resize or rotation: the sketch is solved and shown, the
@@ -399,21 +481,24 @@ fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::Li
let (out, report) = super::sketch::edit_solved(s, id, false, solidcraft_sketch::Analysis::Full, |sk, doc| {
Ok((apply_text_edits(sk, doc, p)?, super::sketch::Left::Unsolved))
})?;
Ok((out, report.map(|report| crate::LiveEdit { sketch: id, report })))
Ok((reply(out)?, report.map(|report| crate::LiveEdit { sketch: id, report })))
}
/// `sketch.edit_text`'s change: one text's (`link` or `entity`), or each of `edits` in turn.
/// One command, so the features after the sketch are rebuilt once for all of them.
fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<EditTextResult> {
let cmd = "sketch.edit_text";
match p.get("edits") {
None | Some(Value::Null) => apply_text_edit(sk, doc, p),
None | Some(Value::Null) => {
let change = apply_text_edit(sk, doc, p)?;
Ok(EditTextResult::One(TextResult { change, created: None, param_warnings: Vec::new() }))
}
Some(Value::Array(edits)) => {
if edits.is_empty() || edits.len() > MAX_TEXT_EDITS {
return Err(bad(cmd, format!("`edits` must hold 1 to {MAX_TEXT_EDITS} edits")));
}
let outs = edits.iter().map(|e| apply_text_edit(sk, doc, e)).collect::<Result<Vec<_>>>()?;
Ok(json!({"edits": outs}))
Ok(EditTextResult::Many(TextEditsResult { edits: outs, created: None, param_warnings: Vec::new() }))
}
Some(_) => Err(bad(cmd, "`edits` must be a list of edits")),
}
@@ -421,7 +506,7 @@ fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value)
/// `sketch.edit_text`'s change to its sketch: the text source's new values and its outlines
/// regenerated from them.
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<TextChange> {
let cmd = "sketch.edit_text";
let l = match (str_(p, "link"), str_(p, "entity")) {
(Some(l), _) => l.to_string(),
@@ -438,15 +523,11 @@ fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(sk, &src);
let exprs = text_exprs_json(&src);
let exprs = text_exprs(&src);
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(sk, &sk.link_curves(&l));
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
}
Ok(out)
Ok(TextChange { link: l, curves, guide, exprs })
}
/// The curve ending at point `pi` and the direction leaving it there.
@@ -552,7 +633,7 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
solve_new_only(sk, first_new);
Ok((ids_of(sk, &[sp]), cons))
})?;
Ok(result(out, info))
drawn(out, info)
}
fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
@@ -577,7 +658,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(ids.len())
})?;
Ok(json!({"changed": n, "sketch": info}))
reply(LineTypeResult { changed: n, sketch: info, created: None, param_warnings: Vec::new() })
}
fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
@@ -601,7 +682,7 @@ fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(ids.len())
})?;
Ok(json!({"changed": n, "sketch": info}))
reply(LineTypeResult { changed: n, sketch: info, created: None, param_warnings: Vec::new() })
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
@@ -659,7 +740,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
];
Ok((ids_of(sk, &[e]), cons))
})?;
Ok(result(out, info))
drawn(out, info)
}
fn point_list(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
@@ -704,7 +785,7 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
mark_construction(sk, &[c], p);
Ok((ids_of(sk, &[c]), Vec::new()))
})?;
Ok(result(out, info))
drawn(out, info)
}
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
@@ -790,7 +871,7 @@ fn conic(s: &mut Session, p: &Value) -> Result<Value> {
mark_construction(sk, &[c], p);
Ok((ids_of(sk, &[c]), Vec::new()))
})?;
Ok(result(out, info))
drawn(out, info)
}
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
@@ -812,7 +893,7 @@ fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
mark_construction(sk, &[l], p);
Ok((ids_of(sk, &[l]), vec![k]))
})?;
Ok(result(out, info))
drawn(out, info)
}
/// Direction leaving curve `ci` at its end point `pi` (continuing the curve past that end).
@@ -966,7 +1047,7 @@ fn arc_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let k = add_c(sk, ConstraintKind::Tangent { a: ci, b: a })?;
Ok((ids_of(sk, &[a]), vec![k]))
})?;
Ok(result(out, info))
drawn(out, info)
}
/// Initial radius for a circle near `at` touching the curves: the mean distance to them.
@@ -1052,7 +1133,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
if info["solved"] != json!(true) {
return Err(bad(cmd, "no tangent circle there"));
}
Ok(result(out, info))
drawn(out, info)
}
fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1079,7 +1160,8 @@ fn slot_center_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.center_point";
let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
slot(s, p, cmd, c * 2.0 - e, e, w)
let (out, info) = slot_parts(s, p, cmd, c * 2.0 - e, e, w)?;
drawn(out, info)
}
/// Arc slot around `o` (centre-line radius `r`) from angle `t0` sweeping `sweep` (> 0).
@@ -1117,9 +1199,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe
}
Ok((ids_of(sk, &[outer, cap_e, inner, cap_s]), cons))
})?;
let mut out = result(out, info);
out["centre_arc"] = json!(centre_arc);
Ok(out)
reply(ArcSlotResult { drawn: drawn_result(out, info), centre_arc })
}
fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1534,7 +1614,7 @@ mod blend_tests {
assert_eq!(ids(&r["created"]["curves"]), vec![sp.clone()], "{r}");
assert_eq!(ids(&r["created"]["points"]).len(), 8, "{r}");
assert!(ids(&r["created"]["constraints"]).is_empty() && ids(&r["created"]["dimensions"]).is_empty(), "{r}");
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "id": "chord"}));
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "ids": "chord"}));
// Drop the two references: what is left is the blend and its chord, one closed region.
run(&mut s, "sketch.delete", json!({"entities": [a, b]}));
let id = s.active_sketch.unwrap();
+451 -26
View File
@@ -5,31 +5,50 @@ use std::collections::HashMap;
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_io::{Geom2, MAX_DRAWING_BYTES};
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
use super::CommandSpec;
use super::sketch::{edit, ids_of};
use super::sketch::{Left, edit_solved, ids_of};
use super::sketch_store;
use crate::params::{bad, num, str_, vec2};
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const INSERT_DXF_CONTRACT: TypedContract<InsertDxfParams> = TypedContract::new();
const INSERT_SVG_CONTRACT: TypedContract<InsertSvgParams> = TypedContract::new();
const EXPORT_DXF_CONTRACT: TypedContract<ExportDxfParams> = TypedContract::new();
/// A command's reply as JSON, built from the type its output schema describes so the two can't
/// drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
CommandSpec::new("sketch.insert_dxf", "Insert DXF", crate::checked!("sketch.insert_dxf", insert_dxf))
.at("SKETCH", "INSERT")
.icon("sketch_dxf")
.contract(&INSERT_DXF_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
.at("SKETCH", "INSERT")
.icon("svg")
.contract(&INSERT_SVG_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
.icon("svg")
.noundo()
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_dxf", export_dxf))
.icon("sketch_dxf")
.noundo()
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
.contract(&EXPORT_DXF_CONTRACT)
.output(output_schema_for::<ExportDxfResult>)
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned); units?: \"design\" | \"in\" | \"inch\" | \"mm\" | \"cm\" | \"m\" | \"ft\" (default mm); layer?: profile layer name (default \"0\"); layers?: \"single\" | \"roles\" (default \"single\": all on `layer`; \"roles\": outer on \"OUTLINE\", holes on \"CUT\"); join?: bool (default false: R12 LINE/ARC; true: closed loops as LWPOLYLINE with arc bulges)"),
];
/// The sketch import command for a drawing file name, regardless of extension case.
@@ -251,10 +270,13 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if geom.len() > solidcraft_sketch::MAX_POINTS {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
// `sketch::edit`, but keeping the solve as a typed value for the reply.
let target = sketch_store::target(s, p, cmd)?;
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
Ok((ids_of(sk, &cs), texts))
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
})?;
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
// Text becomes sketch text.
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
let mut n_text = 0;
@@ -279,7 +301,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
}
}
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -290,39 +312,249 @@ fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "sketch.insert_svg", true)
}
fn sorted_curve_ids<'a>(ids: &'a [String]) -> Vec<&'a str> {
let mut v: Vec<&'a str> = ids.iter().map(String::as_str).collect();
v.sort_unstable();
v
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.export_dxf";
let id = sketch_store::target(s, p, cmd)?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
let sk = &ss.sketch;
let mut geom = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
let pt = |k: usize| sk.point(k).unwrap_or_default();
match &c.kind {
CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
_ => {
let poly = sk.polyline(i);
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
let units_val = p.get("units").filter(|v| !v.is_null());
if let Some(v) = units_val
&& !v.is_string()
{
return Err(bad(cmd, "`units` must be a string"));
}
let units_param = str_(p, "units").map(str::to_ascii_lowercase);
let (insunits, scale) = match units_param.as_deref() {
None => (4, 1.0),
Some("design") => {
let doc_units = solidcraft_doc::expr::length_unit_or_mm(&s.doc.units);
match doc_units.to_ascii_lowercase().as_str() {
"in" | "inch" => (1, 1.0 / 25.4),
"ft" => (2, 1.0 / 304.8),
"cm" => (5, 0.1),
"m" => (6, 0.001),
_ => (4, 1.0),
}
}
Some("in" | "inch") => (1, 1.0 / 25.4),
Some("ft") => (2, 1.0 / 304.8),
Some("mm") => (4, 1.0),
Some("cm") => (5, 0.1),
Some("m") => (6, 0.001),
Some(other) => {
return Err(bad(cmd, format!("unsupported unit `{other}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")")));
}
};
let layer_val = p.get("layer").filter(|v| !v.is_null());
if let Some(v) = layer_val
&& !v.is_string()
{
return Err(bad(cmd, "`layer` must be a string"));
}
let text = solidcraft_io::write_dxf(&geom);
let custom_layer = str_(p, "layer").map(str::trim).filter(|s| !s.is_empty());
let layers_val = p.get("layers").filter(|v| !v.is_null());
if let Some(v) = layers_val
&& !v.is_string()
{
return Err(bad(cmd, "`layers` must be \"single\" or \"roles\""));
}
let roles_layers = match str_(p, "layers").map(str::to_ascii_lowercase).as_deref() {
None | Some("single") => false,
Some("roles") => true,
Some(_) => return Err(bad(cmd, "`layers` must be \"single\" or \"roles\"")),
};
let join = match p.get("join").filter(|v| !v.is_null()) {
None => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`join` must be true or false")),
};
let path_val = p.get("path").filter(|v| !v.is_null());
if let Some(v) = path_val
&& !v.is_string()
{
return Err(bad(cmd, "`path` must be a string"));
}
let is_default = units_val.is_none() && layer_val.is_none() && layers_val.is_none() && !join;
let (text, entity_count) = if is_default {
let mut geom = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
let pt = |k: usize| sk.point(k).unwrap_or_default();
match &c.kind {
CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
_ => {
let poly = sk.polyline(i);
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
}
}
}
let count = geom.len();
(solidcraft_io::write_dxf(&geom), count)
} else if !join {
let hole_keys: std::collections::HashSet<Vec<&str>> =
ss.profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| sorted_curve_ids(h))).collect();
let mut outline_ids = std::collections::HashSet::new();
let mut cut_ids = std::collections::HashSet::new();
if roles_layers {
for prof in &ss.profiles {
for holes in &prof.hole_curves {
for id in holes {
cut_ids.insert(id.as_str());
}
}
if !hole_keys.contains(&sorted_curve_ids(&prof.outer_curves)) {
for id in &prof.outer_curves {
outline_ids.insert(id.as_str());
}
}
}
}
let base_layer = custom_layer.unwrap_or("0");
let mut entities = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
let layer = if roles_layers {
if cut_ids.contains(c.id.as_str()) {
"CUT"
} else if outline_ids.contains(c.id.as_str()) {
"OUTLINE"
} else {
base_layer
}
} else {
base_layer
};
let pt = |k: usize| sk.point(k).unwrap_or_default() * scale;
match &c.kind {
CurveKind::Line { a, b } => entities.push((layer, Geom2::Line(pt(*a), pt(*b)))),
CurveKind::Circle { c, r } => entities.push((layer, Geom2::Circle(pt(*c), *r * scale))),
CurveKind::Arc { c, a, b } => entities.push((layer, Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) })),
_ => {
let poly = sk.polyline(i);
for w in poly.windows(2) {
entities.push((layer, Geom2::Line(w[0] * scale, w[1] * scale)));
}
}
}
}
let count = entities.len();
(solidcraft_io::write_dxf_entities(&entities, insunits), count)
} else {
// join: true -> emit closed loops as LWPOLYLINE with bulges for arcs
let hole_keys: std::collections::HashSet<Vec<&str>> =
ss.profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| sorted_curve_ids(h))).collect();
let mut outline_ids = std::collections::HashSet::new();
let mut cut_ids = std::collections::HashSet::new();
for prof in &ss.profiles {
for id in &prof.outer_curves {
outline_ids.insert(id.as_str());
}
for holes in &prof.hole_curves {
for id in holes {
cut_ids.insert(id.as_str());
}
}
}
let base_layer = custom_layer.unwrap_or("0");
let mut entities = Vec::new();
for prof in &ss.profiles {
if hole_keys.contains(&sorted_curve_ids(&prof.outer_curves)) {
continue;
}
let outer_layer = if roles_layers { "OUTLINE" } else { base_layer };
let shape = solidcraft_io::loop_lwpolyline_scaled(&prof.region.outer, scale);
entities.push(DxfEntity { layer: outer_layer.into(), shape });
for hole in &prof.region.holes {
let hole_layer = if roles_layers { "CUT" } else { base_layer };
let shape = solidcraft_io::loop_lwpolyline_scaled(hole, scale);
entities.push(DxfEntity { layer: hole_layer.into(), shape });
}
}
// Preserve any non-profile open curves as disconnected entities so nothing is lost
for (i, c) in sk.curves.iter().enumerate() {
if c.construction || outline_ids.contains(c.id.as_str()) || cut_ids.contains(c.id.as_str()) {
continue;
}
let pt = |k: usize| sk.point(k).unwrap_or_default() * scale;
match &c.kind {
CurveKind::Line { a, b } => {
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Line(pt(*a), pt(*b)) })
}
CurveKind::Circle { c, r } => {
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Circle { c: pt(*c), r: *r * scale } })
}
CurveKind::Arc { c, a, b } => {
let center = pt(*c);
let pa = pt(*a);
let pb = pt(*b);
let r = center.dist(pa);
let a0 = (pa - center).angle().to_degrees().rem_euclid(360.0);
let a1 = (pb - center).angle().to_degrees().rem_euclid(360.0);
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Arc { c: center, r, a0, a1 } });
}
_ => {
let poly = sk.polyline(i);
let points: Vec<Vec2> = poly.into_iter().map(|p| p * scale).collect();
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Polyline { points } });
}
}
}
let mut layer_names = std::collections::BTreeSet::new();
if roles_layers {
layer_names.insert("OUTLINE".to_string());
layer_names.insert("CUT".to_string());
}
for e in &entities {
layer_names.insert(e.layer.clone());
}
let layers: Vec<DxfLayer> = layer_names
.iter()
.map(|name| match name.as_str() {
"OUTLINE" => DxfLayer::new(name, 7),
"CUT" => DxfLayer::new(name, 4),
_ => DxfLayer::new(name, 7),
})
.collect();
let count = entities.len();
(solidcraft_io::write_dxf_drawing_units(&layers, &entities, insunits)?, count)
};
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "entities": geom.len()}));
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
}
Ok(json!({"dxf": text, "entities": geom.len()}))
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
}
#[cfg(test)]
mod tests {
use serde_json::json;
use solidcraft_geom::prelude::*;
use solidcraft_io::Geom2;
use crate::Session;
@@ -495,4 +727,197 @@ mod tests {
assert!(s.execute("sketch.insert_svg", &p).is_err(), "{bad}");
}
}
#[test]
fn export_dxf_default_is_byte_identical_to_before() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [40, 30]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [15, 15], "radius": 5})).unwrap();
let out = s.execute("sketch.export_dxf", &json!({})).unwrap();
let text = out["dxf"].as_str().unwrap();
// Build expected R12 DXF exactly as before
let st = s.model.state();
let ss = st.sketch(1).unwrap();
let sk = &ss.sketch;
let mut geom = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
let pt = |k: usize| sk.point(k).unwrap_or_default();
match &c.kind {
solidcraft_sketch::CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
solidcraft_sketch::CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
solidcraft_sketch::CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
_ => {
let poly = sk.polyline(i);
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
}
}
}
let expected = solidcraft_io::write_dxf(&geom);
assert_eq!(text, expected, "default output must be byte-identical to previous write_dxf output");
// Null options must be treated as omitted (default behavior)
for null_params in [
json!({"units": null}),
json!({"layer": null}),
json!({"layers": null}),
json!({"join": null}),
json!({"path": null}),
json!({"units": null, "layer": null, "layers": null, "join": null, "path": null}),
] {
let out_null = s.execute("sketch.export_dxf", &null_params).unwrap();
assert_eq!(out_null["dxf"].as_str().unwrap(), expected, "null options must behave identically to default options");
assert_eq!(out_null["entities"], out["entities"]);
}
// Hostile inputs
assert!(s.execute("sketch.export_dxf", &json!({"units": 123})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"units": "unknown_unit"})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"layer": 456})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"layers": 123})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"layers": "all"})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"join": "true"})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"path": 123})).is_err());
}
#[test]
fn export_dxf_layers_present() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [50, 50]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [25, 25], "radius": 10})).unwrap();
// 1. Single custom layer
let out_cut = s.execute("sketch.export_dxf", &json!({"layer": "CUT"})).unwrap();
let dxf_cut = out_cut["dxf"].as_str().unwrap();
assert!(dxf_cut.contains("\n8\nCUT\n"));
assert!(!dxf_cut.contains("\n8\n0\n"));
// 2. Roles layers (OUTLINE for outer profile, CUT for holes)
let out_roles = s.execute("sketch.export_dxf", &json!({"layers": "roles"})).unwrap();
let dxf_roles = out_roles["dxf"].as_str().unwrap();
assert!(dxf_roles.contains("\n8\nOUTLINE\n"));
assert!(dxf_roles.contains("\n8\nCUT\n"));
// 3. Roles layers with join: true
let out_joined_roles = s.execute("sketch.export_dxf", &json!({"join": true, "layers": "roles"})).unwrap();
assert_eq!(out_joined_roles["entities"], 2);
let dxf_joined = out_joined_roles["dxf"].as_str().unwrap();
assert!(dxf_joined.contains("AcDbPolyline"));
assert!(dxf_joined.contains("\n 8\nOUTLINE\n") || dxf_joined.contains("\n8\nOUTLINE\n"));
assert!(dxf_joined.contains("\n 8\nCUT\n") || dxf_joined.contains("\n8\nCUT\n"));
}
#[test]
fn export_dxf_join_does_not_duplicate_holes() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [100, 80]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [30, 40], "radius": 5})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [70, 40], "radius": 8})).unwrap();
// With join: true, layers: "roles": exactly 3 entities (1 outer outline, 2 hole cuts)
let out_roles = s.execute("sketch.export_dxf", &json!({"join": true, "layers": "roles"})).unwrap();
assert_eq!(out_roles["entities"], 3, "expected 1 outer outline and 2 hole cuts, got {}", out_roles["entities"]);
let dxf_roles = out_roles["dxf"].as_str().unwrap();
let lwpolyline_count = dxf_roles.lines().filter(|l| l.trim() == "LWPOLYLINE").count();
assert_eq!(lwpolyline_count, 3, "expected exactly 3 LWPOLYLINE entities in DXF");
// Without roles (single layer): still exactly 3 entities
let out_single = s.execute("sketch.export_dxf", &json!({"join": true})).unwrap();
assert_eq!(out_single["entities"], 3, "entities without roles must also be exactly 3");
}
#[test]
fn export_dxf_join_closed_lwpolyline_with_bulge() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
// Quarter-pie shape: center at (0, 0), arc from (10, 0) to (0, 10), and lines to (0, 0)
s.execute("sketch.line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.execute("sketch.arc.center_point", &json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]})).unwrap();
s.execute("sketch.line", &json!({"points": [[0, 10], [0, 0]]})).unwrap();
let out = s.execute("sketch.export_dxf", &json!({"join": true})).unwrap();
let dxf = out["dxf"].as_str().unwrap();
assert!(dxf.contains("LWPOLYLINE"));
// 70: bit 1 (closed) + bit 128 (plinegen) = 129
assert!(dxf.contains("\n 70\n129\n") || dxf.contains("\n70\n129\n"));
// Bulge: 90° arc has sweep = pi/2, bulge = tan(sweep / 4) = tan(pi / 8) = sqrt(2) - 1 ≈ 0.414213562373
let expected_bulge = (std::f64::consts::FRAC_PI_2 / 4.0).tan();
let has_bulge = dxf
.lines()
.zip(dxf.lines().skip(1))
.any(|(k, v)| k.trim() == "42" && (v.trim().parse::<f64>().unwrap_or(0.0).abs() - expected_bulge).abs() < 1e-6);
assert!(has_bulge, "DXF must contain group 42 bulge approx {expected_bulge}");
// Re-import the joined DXF into a new sketch and verify round-trip profile area
s.execute("sketch.finish", &json!({})).unwrap();
let r = s.execute("sketch.insert_dxf", &json!({"text": dxf, "at": [100, 0]})).unwrap();
let id = r["sketch"].as_u64().unwrap();
let ss = s.model.state().sketch(id).unwrap().clone();
assert_eq!(ss.profiles.len(), 1);
let expected_area = std::f64::consts::PI * 100.0 / 4.0; // quarter circle of radius 10
assert!((ss.profiles[0].area - expected_area).abs() < 1e-4, "area was {}", ss.profiles[0].area);
}
#[test]
fn export_dxf_inch_design_round_trip() {
let mut s = Session::default();
s.doc_mut().units = "in".to_string();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
// A 2 in by 3 in rectangle in an inch design (stored internally as 50.8 mm by 76.2 mm)
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [50.8, 76.2]})).unwrap();
// 1. Export with units: "design"
let out = s.execute("sketch.export_dxf", &json!({"units": "design"})).unwrap();
let dxf = out["dxf"].as_str().unwrap();
// $INSUNITS must be 1 (inches)
assert!(dxf.contains("\n$INSUNITS\n70\n1\n"));
// Sizes in inches: 2.0 and 3.0
assert!(dxf.contains("2.000000000"));
assert!(dxf.contains("3.000000000"));
// Round-trip through read_dxf: reads $INSUNITS=1, scales by 25.4 to mm
let geom = solidcraft_io::read_dxf(dxf).unwrap();
let mut widths = Vec::new();
let mut heights = Vec::new();
for g in &geom {
if let Geom2::Line(a, b) = g {
let dx = (b.x - a.x).abs() / 25.4;
let dy = (b.y - a.y).abs() / 25.4;
if dx > 0.1 {
widths.push(dx);
}
if dy > 0.1 {
heights.push(dy);
}
}
}
for w in widths {
assert!((w - 2.0).abs() < 1e-8, "width in inches was {w}");
}
for h in heights {
assert!((h - 3.0).abs() < 1e-8, "height in inches was {h}");
}
// 2. Export with join: true and explicit units: "in"
let out_join = s.execute("sketch.export_dxf", &json!({"join": true, "units": "in"})).unwrap();
let dxf_join = out_join["dxf"].as_str().unwrap();
assert!(dxf_join.contains("LWPOLYLINE"));
assert!(dxf_join.contains("\n$INSUNITS\n 70\n1\n") || dxf_join.contains("\n$INSUNITS\n70\n1\n"));
let geom_join = solidcraft_io::read_dxf(dxf_join).unwrap();
assert_eq!(geom_join.len(), 4);
for g in &geom_join {
if let Geom2::Line(a, b) = g {
let len_in = a.dist(*b) / 25.4;
assert!((len_in - 2.0).abs() < 1e-8 || (len_in - 3.0).abs() < 1e-8, "edge length {len_in}");
}
}
}
}
+166 -71
View File
@@ -13,6 +13,12 @@ use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, str_, vec3};
use crate::schema::sketch_project::{
AutoProjectParams, AutoProjectResult, FitCurvesParams, FitSectionResult, Include3dParams, IntersectParams, IntersectionCurveParams,
IsoCurveParams, LinkMade, ProjectParams, ProjectResult, ProjectToSurfaceParams, ProjectToSurfaceResult, ProjectedLink, SketchBreakLinkParams,
SketchBreakLinkResult, SketchLinkResult, SpunProfileParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
macro_rules! refs_doc {
@@ -21,105 +27,160 @@ macro_rules! refs_doc {
};
}
const PROJECT_CONTRACT: TypedContract<ProjectParams> = TypedContract::new();
const INTERSECT_CONTRACT: TypedContract<IntersectParams> = TypedContract::new();
const INCLUDE_3D_CONTRACT: TypedContract<Include3dParams> = TypedContract::new();
const PROJECT_TO_SURFACE_CONTRACT: TypedContract<ProjectToSurfaceParams> = TypedContract::new();
const INTERSECTION_CURVE_CONTRACT: TypedContract<IntersectionCurveParams> = TypedContract::new();
const SPUN_PROFILE_CONTRACT: TypedContract<SpunProfileParams> = TypedContract::new();
const ISO_CURVE_CONTRACT: TypedContract<IsoCurveParams> = TypedContract::new();
const FIT_SECTION_CONTRACT: TypedContract<FitCurvesParams> = TypedContract::new();
const BREAK_LINK_CONTRACT: TypedContract<SketchBreakLinkParams> = TypedContract::new();
const AUTO_PROJECT_CONTRACT: TypedContract<AutoProjectParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
CommandSpec::new("sketch.project", "Project", crate::checked!("sketch.project", project_cmd)).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).contract(&PROJECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
concat!(refs_doc!(), "; projection_link?: bool (default true); construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!()),
),
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true)", created_doc!()),
CommandSpec::new("sketch.intersect", "Intersect", crate::checked!("sketch.intersect", intersect_cmd)).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).contract(&INTERSECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true); construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!()),
),
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", crate::checked!("sketch.include_3d", include_cmd))
.at("SKETCH", "CREATE")
.icon("include")
.enabled(in_sketch)
.params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
.contract(&INCLUDE_3D_CONTRACT).output(output_schema_for::<ProjectResult>).params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it; construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!())),
CommandSpec::new("sketch.project_to_surface", "Project To Surface", crate::checked!("sketch.project_to_surface", project_to_surface))
.at("SKETCH", "CREATE")
.icon("project_surface")
.enabled(in_sketch)
.params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
.contract(&PROJECT_TO_SURFACE_CONTRACT).output(output_schema_for::<ProjectToSurfaceResult>).params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", crate::checked!("sketch.intersection_curve", intersection_curve))
.at("SKETCH", "CREATE")
.icon("intersection_curve")
.enabled(in_sketch)
.params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
.contract(&INTERSECTION_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.spun_profile", "Spun Profile", crate::checked!("sketch.spun_profile", spun_profile))
.at("SKETCH", "CREATE")
.icon("spun")
.enabled(in_sketch)
.params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
.contract(&SPUN_PROFILE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", crate::checked!("sketch.isoparametric_curve", iso_curve))
.at("SKETCH", "CREATE")
.icon("iso_curve")
.enabled(in_sketch)
.params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
.contract(&ISO_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", crate::checked!("sketch.fit_curves_to_section", fit_section))
.at("SKETCH", "CREATE")
.icon("fit_section")
.enabled(in_sketch)
.params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
CommandSpec::new("sketch.break_link", "Break Link", break_link).icon("sketch_break")
.contract(&FIT_SECTION_CONTRACT).output(output_schema_for::<FitSectionResult>).params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
CommandSpec::new("sketch.break_link", "Break Link", crate::checked!("sketch.break_link", break_link)).icon("sketch_break")
.enabled(in_sketch)
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project).icon("project")
.contract(&BREAK_LINK_CONTRACT).output(output_schema_for::<SketchBreakLinkResult>).params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", crate::checked!("sketch.auto_project", auto_project)).icon("project")
.noundo()
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
.contract(&AUTO_PROJECT_CONTRACT).output(output_schema_for::<AutoProjectResult>).params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
];
/// Parse one reference.
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
/// What a reference says in its JSON, before the design is asked what it names (`source` goes on
/// from here, and so does the contract of the commands that take references).
pub(crate) enum RefForm<'a> {
/// Text: `origin`, an axis letter or a body name, which the design decides.
Name(&'a str),
/// `{edge: [x,y,z], body?}`.
Edge { body: String, at: Vec3 },
/// `{face: [x,y,z], body?}`.
Face { body: String, at: Vec3 },
/// `{vertex: [x,y,z], body?}`.
Vertex { body: String, at: Vec3 },
/// `{sketch, curve}` or `{sketch, point}`: the sketch as the lookup reads it (an id or a name)
/// and the `curve` or `point` given. With neither, the handler refuses it once the sketch is
/// found.
Sketch { key: String, curve: Option<&'a str>, point: Option<&'a str> },
/// `{body: name}`.
Body(&'a str),
/// `{axis: X|Y|Z|name}`.
Axis(&'a str),
/// `{plane: XY|XZ|YZ|name}`.
Plane(&'a str),
/// `{origin: …}`.
Origin,
}
/// The form of one reference, or the message (without the command's name) for what no design
/// could make sense of. The forms are tried in this order, the first that fits wins: an edge, face
/// or vertex whose value is not a point is passed over, not refused.
pub(crate) fn ref_form(v: &Value) -> std::result::Result<RefForm<'_>, String> {
if let Some(name) = v.as_str() {
return match name.to_ascii_lowercase().as_str() {
"origin" => Ok(LinkSource::Origin),
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
};
return Ok(RefForm::Name(name));
}
let body = || str_(v, "body").unwrap_or("").to_string();
if let Some(at) = v.get("edge").and_then(vec3) {
return Ok(LinkSource::Edge { body: body(), at });
return Ok(RefForm::Edge { body: body(), at });
}
if let Some(at) = v.get("face").and_then(vec3) {
return Ok(LinkSource::Face { body: body(), at });
return Ok(RefForm::Face { body: body(), at });
}
if let Some(at) = v.get("vertex").and_then(vec3) {
return Ok(LinkSource::Vertex { body: body(), at });
return Ok(RefForm::Vertex { body: body(), at });
}
if let Some(sk) = v.get("sketch") {
let key = match sk {
Value::Number(n) => n.to_string(),
Value::String(x) => x.clone(),
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
_ => return Err("`sketch` must be an id or a name".into()),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad(cmd, format!("`{key}` is not a sketch")));
}
if Some(f.id) == s.active_sketch {
return Err(bad(cmd, "a sketch cannot project its own geometry"));
}
if let Some(c) = str_(v, "curve") {
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
}
if let Some(p) = str_(v, "point") {
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
}
return Err(bad(cmd, "a sketch reference needs `curve` or `point`"));
return Ok(RefForm::Sketch { key, curve: str_(v, "curve"), point: str_(v, "point") });
}
if let Some(b) = str_(v, "body") {
return Ok(LinkSource::Body { body: b.to_string() });
return Ok(RefForm::Body(b));
}
if let Some(a) = str_(v, "axis") {
return axis_source(s, a, cmd);
return Ok(RefForm::Axis(a));
}
if let Some(p) = str_(v, "plane") {
return Ok(LinkSource::Plane { name: p.to_string() });
return Ok(RefForm::Plane(p));
}
if v.get("origin").is_some() {
return Ok(LinkSource::Origin);
return Ok(RefForm::Origin);
}
Err(format!("cannot project {v}"))
}
/// Parse one reference.
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
match ref_form(v).map_err(|why| bad(cmd, why))? {
RefForm::Name(name) => match name.to_ascii_lowercase().as_str() {
"origin" => Ok(LinkSource::Origin),
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
},
RefForm::Edge { body, at } => Ok(LinkSource::Edge { body, at }),
RefForm::Face { body, at } => Ok(LinkSource::Face { body, at }),
RefForm::Vertex { body, at } => Ok(LinkSource::Vertex { body, at }),
RefForm::Sketch { key, curve, point } => {
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad(cmd, format!("`{key}` is not a sketch")));
}
if Some(f.id) == s.active_sketch {
return Err(bad(cmd, "a sketch cannot project its own geometry"));
}
if let Some(c) = curve {
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
}
if let Some(p) = point {
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
}
Err(bad(cmd, "a sketch reference needs `curve` or `point`"))
}
RefForm::Body(b) => Ok(LinkSource::Body { body: b.to_string() }),
RefForm::Axis(a) => axis_source(s, a, cmd),
RefForm::Plane(p) => Ok(LinkSource::Plane { name: p.to_string() }),
RefForm::Origin => Ok(LinkSource::Origin),
}
Err(bad(cmd, format!("cannot project {v}")))
}
/// An origin axis (`X`, `Y` or `Z`, in any case), or a construction axis by its name (#9324).
@@ -391,10 +452,37 @@ fn add_link_unless_present(
Ok(Some(link))
}
fn link_json(sk: &Sketch, link: &str) -> Value {
/// Make the curves of `link` construction geometry: references to draw against, left out of
/// profiles (#13142). They stay linked; a link rebuilt because its shape changed comes back as
/// normal geometry, as curves switched to construction by hand do.
fn mark_construction(sk: &mut Sketch, link: &str) {
for c in sk.link_curves(link) {
if let Some(curve) = sk.curves.get_mut(c) {
curve.construction = true;
}
}
}
/// [`mark_construction`] for `link` in sketch `id`, as the session's preference asks of the face
/// edges a new sketch projects (#13142).
pub(super) fn construct_link(s: &mut Session, id: u64, link: &str) -> Result<()> {
sketch_store::update(s, id, |_, _, sk| {
mark_construction(sk, link);
Ok(())
})
}
/// A command's reply as JSON: they build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// The ids of the curves and of the points that link `link` made.
fn link_parts(sk: &Sketch, link: &str) -> (Vec<String>, Vec<String>) {
let curves: Vec<String> = sk.link_curves(link).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
let points: Vec<String> = sk.link_points(link).iter().filter_map(|p| sk.points.get(*p).map(|p| p.id.clone())).collect();
json!({"link": link, "curves": curves, "points": points})
(curves, points)
}
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_link: bool) -> Result<Value> {
@@ -406,6 +494,11 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
if refs.is_empty() {
return Err(bad(cmd, "`refs` is empty"));
}
let construction = match p.get("construction") {
None => s.project_construction,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(bad(cmd, "`construction` must be a boolean")),
};
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let sources: Vec<LinkSource> = refs.iter().map(|v| source(s, v, cmd)).collect::<Result<_>>()?;
let (out, present) = sketch_store::update(s, id, |s, doc, sk| {
@@ -418,9 +511,13 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
let what = src.describe();
match add_link_unless_present(s, doc, id, sk, kind, src, kind == LinkKind::Project) {
Ok(Some(l)) => {
let mut result = link_json(sk, &l);
let (curves, points) = link_parts(sk, &l);
if construction {
mark_construction(sk, &l);
}
let mut result = ProjectedLink { link: Some(l.clone()), curves, points };
if !projection_link {
result["link"] = Value::Null;
result.link = None;
sk.break_link(&l)?;
}
out.push(result);
@@ -435,11 +532,7 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
}
Ok((out, present))
})?;
let mut r = json!({"links": out});
if !present.is_empty() {
r["already_projected"] = json!(present);
}
Ok(r)
reply(cmd, ProjectResult { links: out, already_projected: present, created: None, param_warnings: Vec::new() })
}
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -543,16 +636,15 @@ pub(super) fn project_entities(s: &mut Session, p: &Value, cmd: &str) -> Result<
}
/// Add one link from `src` to the active sketch and report it.
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<LinkMade> {
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
sketch_store::update(s, id, |s, doc, sk| {
refresh(s, doc, id, sk);
let what = src.describe();
let l = add_link(s, doc, id, sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
let mut out = link_json(sk, &l);
let (curves, points) = link_parts(sk, &l);
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
out["wires"] = json!(wires);
Ok(out)
Ok(LinkMade { link: l, curves, points, wires })
})
}
@@ -571,7 +663,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
};
out.push(one_link(s, cmd, LinkKind::Project, LinkSource::OnSurface { sketch, curve, body: body.clone(), at })?);
}
Ok(json!({"links": out}))
reply(cmd, ProjectToSurfaceResult { links: out, created: None, param_warnings: Vec::new() })
}
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
@@ -584,7 +676,8 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let (a, b) = (side("a")?, side("b")?);
one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })?;
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
}
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
@@ -605,7 +698,8 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
(wa, wb - wa)
}
};
one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })
let made = one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })?;
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
}
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
@@ -620,7 +714,8 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
format!("{},{},{}", d.x, d.y, d.z)
}
};
one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })?;
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
}
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
@@ -636,7 +731,7 @@ fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
sk.break_link(&l)?;
Ok(curves)
})?;
Ok(json!({"curves": curves}))
reply(cmd, FitSectionResult { curves, created: None, param_warnings: Vec::new() })
}
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
@@ -670,12 +765,12 @@ fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(links)
})?;
Ok(json!({"broken": links}))
reply(cmd, SketchBreakLinkResult { broken: links, created: None, param_warnings: Vec::new() })
}
fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
s.auto_project = bool_(p, "value").unwrap_or(!s.auto_project);
Ok(json!({"auto_project": s.auto_project}))
reply("sketch.auto_project", AutoProjectResult { auto_project: s.auto_project, created: None, param_warnings: Vec::new() })
}
/// `"x:<a>+<b>:x,y"` from interactive snapping: two distinct curve ids and a hint point.
@@ -1763,3 +1763,43 @@ fn a_second_placements_face_and_edge_project_where_it_is_placed() {
check(&s, 15.0);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()), 15.0);
}
/// #13142: projected references can start as construction geometry, asked for per call or for
/// the whole session (a preference, kept for designs started and opened like `auto_project`).
#[test]
fn projected_references_can_start_as_construction() {
let flags = |s: &mut Session| linked_curves(&inspect(s)).iter().map(|c| c["construction"] == true).collect::<Vec<_>>();
let mut s = Session::default();
box_part(&mut s);
// Off (the default): the face loop is normal geometry and closes a profile.
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let on_face = flags(&mut s);
assert!(on_face.len() == 4 && on_face.iter().all(|c| !c), "{on_face:?}");
assert_eq!(inspect(&mut s)["profiles"].as_array().map(Vec::len), Some(1));
run(&mut s, "sketch.finish", json!({}));
// Asked for in the call, and not.
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 20]}], "construction": true}));
assert_eq!(flags(&mut s), vec![true]);
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}]}));
assert_eq!(flags(&mut s), vec![true, false]);
run(&mut s, "sketch.intersect", json!({"refs": [{"edge": [0, 0, 10]}], "construction": true}));
let e = s.execute("sketch.project", &json!({"refs": [{"edge": [20, 30, 20]}], "construction": 1}));
assert!(e.is_err(), "construction must be a boolean");
run(&mut s, "sketch.finish", json!({}));
// The session's preference: a sketch on a face starts with a construction loop and no
// profile, and it holds for the next design; `construction: false` still asks for normal.
s.project_construction = true;
run(&mut s, "file.new", json!({}));
assert!(s.project_construction, "file.new");
box_part(&mut s);
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let on_face = flags(&mut s);
assert!(on_face.len() == 4 && on_face.iter().all(|c| *c), "{on_face:?}");
assert_eq!(inspect(&mut s)["profiles"].as_array().map(Vec::len), Some(0));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 20]}]}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}], "construction": false}));
assert_eq!(flags(&mut s), vec![true, false]);
}
+57
View File
@@ -340,6 +340,63 @@ fn tangent_moves_only_the_second_pick() {
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
}
/// #12092: Tangent with a line's end point and a circle puts that end on the circle and makes the
/// line tangent there, in one step. In either pick order only the picked end moves: the line turns
/// about its other end and gets longer or shorter, and the circle stays.
#[test]
fn tangent_at_a_lines_end_puts_the_end_on_the_circle() {
// Where the line ends up: its end's distance from the centre, and the line's.
let touch = |s: &Session| {
let sk = sketch(s);
let (a, b) = (pt(s, "l1.start"), pt(s, "l1.end"));
let c = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap();
let line = (b - a).cross(c - a).abs() / a.dist(b);
(b.dist(c), line, sk.radius(sk.curve_index("c1").unwrap()).unwrap())
};
for (a, b) in [("c1", "l1.end"), ("l1.end", "c1")] {
let mut s = line_and_circle();
let circle = snap(&s, "c1");
let undo = s.undo.len();
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
let (end, line, radius) = touch(&s);
assert!((radius - 5.0).abs() < 1e-9, "{a}, {b}: the circle keeps its size: {radius}");
assert!((end - 5.0).abs() < 1e-6 && (line - 5.0).abs() < 1e-6, "{a}, {b}: end {end}, line {line} from the centre");
assert!(same(&snap(&s, "c1"), &circle), "{a}, {b}: the circle moved");
let start = pt(&s, "l1.start");
assert!(start.dist(solidcore_record::Vec2::new(5.0, 3.0)) < 1e-9, "{a}, {b}: the line's other end moved to {start:?}");
assert_eq!(s.undo.len(), undo + 1, "{a}, {b}: one undo step");
}
// A point that ends no curve names no curve to be tangent: the refusal says what to pick.
let mut s = line_and_circle();
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1.center"})).unwrap_err().to_string();
assert!(e.contains("end"), "{e}");
}
/// A line whose end is already joined to an arc's end (the coincident a click there adds): Tangent
/// on that end and the arc, or on the two ends in either order, adds only the tangent, since the
/// end already lies on the arc. The joint holds. With the end and the arc the line stays and the
/// arc moves; with two ends the first pick stays.
#[test]
fn tangent_at_a_joint_adds_only_the_tangent() {
for (a, b, stays) in [("l1.end", "a1", "l1"), ("a1", "l1.end", "l1"), ("l1.end", "a1.start", "l1"), ("a1.start", "l1.end", "a1")] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[2, 3], [12, 3]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [16, 9], "start": [12, 3], "sweep": 90}));
let joints = sketch(&s).constraints.len();
let kept = snap(&s, stays);
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert!(same(&snap(&s, stays), &kept), "{a}, {b}: {stays} moved");
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(1), "{a}, {b}: {r}");
assert_eq!(sketch(&s).constraints.len(), joints + 1, "{a}, {b}: only the tangent is new");
let sk = sketch(&s);
let arc = sk.curve_index("a1").unwrap();
let (p, q, c) = (pt(&s, "l1.start"), pt(&s, "l1.end"), sk.center(arc).unwrap());
assert!(q.dist(pt(&s, "a1.start")) < 1e-6, "{a}, {b}: the joint came apart");
assert!(((q - p).cross(c - p).abs() / p.dist(q) - sk.radius(arc).unwrap()).abs() < 1e-6, "{a}, {b}: not tangent");
}
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
+12 -622
View File
@@ -1,642 +1,32 @@
//! The control channel's per-launch token (docs/control-protocol.md). `solidcraft --control PORT`
//! makes a random token, writes it to a file only the user can read
//! ([`token_path`]) and refuses every connection whose first request doesn't carry it, so other
//! users on the same machine (loopback is shared, on Windows across remote-desktop sessions too)
//! can't drive the app. Clients find it with [`client_token`]. Next to it, `<port>.pid` names
//! the process that wrote it, so a client refused with a leftover token can say the file is stale.
//! SolidCraft's control channel identity for the shared token files (`solidcraft_control::auth`):
//! its name, data folder, `SOLIDCRAFT_` environment and default port (docs/control-protocol.md).
use std::io::{Error, ErrorKind, Read, Result, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use solidcraft_control::auth::App;
/// Shortest token accepted from `SOLIDCRAFT_CONTROL_TOKEN` (hex characters).
pub const MIN_TOKEN_LEN: usize = 32;
/// The port the control channel tries first when `--control` and `SOLIDCRAFT_CONTROL_PORT` give none.
/// Below the ephemeral ranges of Linux (32768+) and Windows/macOS (49152+), so the system never
/// hands it to an outgoing connection.
pub const DEFAULT_PORT: u16 = 27878;
/// Ports tried from [`DEFAULT_PORT`] upward when it is taken (a second window, another program).
pub const PORT_TRIES: u16 = 16;
/// Where the control channel listens, from the launch's `--control`/environment port and the
/// "Let AI agents connect" preference.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Listen {
/// Exactly this port (`--control PORT`, `SOLIDCRAFT_CONTROL_PORT`); no fallback.
Exact(u16),
/// [`DEFAULT_PORT`], or the next free one of [`PORT_TRIES`].
Default,
}
/// What a launch listens on. An explicit port wins over the preference, so a scripted launch
/// works with the setting off; with the setting off and no port, nothing listens.
pub fn plan(explicit: Option<u16>, enabled: bool) -> Option<Listen> {
match explicit {
Some(port) => Some(Listen::Exact(port)),
None if enabled => Some(Listen::Default),
None => None,
}
}
/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to
/// `tries` ports in all.
pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListener> {
let mut last = Error::new(ErrorKind::AddrInUse, "no port to try");
for port in (first..=u16::MAX).take(usize::from(tries.max(1))) {
match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => return Ok(l),
// In use; Windows reports a port another program holds exclusively as denied.
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e,
Err(e) => return Err(e),
}
}
Err(last)
}
/// The folder of the token files: `<data folder>/control` (`SOLIDCRAFT_CONTROL_DIR` replaces it).
fn control_dir() -> Option<PathBuf> {
match custom_control_dir() {
Some(d) => Some(PathBuf::from(d)),
None => Some(crate::recovery::data_dir()?.join("control")),
}
}
/// The folder `SOLIDCRAFT_CONTROL_DIR` names in place of the default one.
fn custom_control_dir() -> Option<std::ffi::OsString> {
solidcraft_geom::env::var_os("CONTROL_DIR").filter(|d| !d.is_empty())
}
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
/// (`SOLIDCRAFT_CONTROL_DIR` replaces the folder).
pub fn token_path(port: u16) -> Option<PathBuf> {
Some(control_dir()?.join(format!("{port}.token")))
}
/// The discovery file: the port of the most recently started app, so a client with no port
/// (`solidcraft-cli mcp`) finds it. It holds only the port; the token stays in its own file.
pub fn current_port_path() -> Option<PathBuf> {
Some(control_dir()?.join("current.port"))
}
/// Record `port` as the running app's, readable only by the user like the token files.
pub fn write_current_port(port: u16) -> Result<PathBuf> {
let path = current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?;
write_token_file(&path, &port.to_string())?;
Ok(path)
}
/// The port the discovery file names, if there is one.
pub fn read_current_port() -> Option<u16> {
parse_port(&std::fs::read_to_string(current_port_path()?).ok().filter(|t| t.len() <= 64)?)
}
fn parse_port(text: &str) -> Option<u16> {
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
}
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
/// over: it names another port, or another launch now holds this port's token file. Under
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
/// number and write the same file names; the one closing first must leave the other's (#8794).
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
pub fn remove_current_port(port: u16, token: &str) {
if let (Some(current), Some(file)) = (current_port_path(), token_path(port)) {
remove_discovery(&current, &file, port, token);
}
}
/// [`remove_current_port`] on given files: `current` is the discovery file, `file` the token file
/// of `port`.
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
if named != Some(port) || held_by_another(file, token) {
return;
}
let _ = std::fs::remove_file(current);
let _ = std::fs::remove_file(pid_path(current));
}
/// A token set on purpose with `SOLIDCRAFT_CONTROL_TOKEN` (test harnesses, sandboxed packages
/// whose data folder the client can't see), if it is long enough.
pub fn env_token() -> Option<String> {
solidcraft_geom::env::var("CONTROL_TOKEN").map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
}
/// The token a client sends to the app on `port`: `explicit` (`--token`), else
/// `SOLIDCRAFT_CONTROL_TOKEN`, else the app's token file.
pub fn client_token(port: u16, explicit: Option<&str>) -> Option<ClientToken> {
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
return Some(ClientToken { token: t.to_string(), file: None });
}
if let Some(t) = env_token() {
return Some(ClientToken { token: t, file: None });
}
// Read directly, not through `vfs`: the app's own file, outside any agent's folders.
read_token_file(&token_path(port)?)
}
/// A client token and, when it came from a token file, where that file is.
pub struct ClientToken {
pub token: String,
pub file: Option<TokenSource>,
}
/// The token file a client read: its path, when it was written and the process that wrote it.
pub struct TokenSource {
pub path: PathBuf,
pub modified: Option<SystemTime>,
/// From `<port>.pid`: the writer's process id and, on Linux, its start time.
pub writer: Option<(u32, Option<u64>)>,
}
impl ClientToken {
/// The app's `error`, and for a refused token read from a file, which file it was, when it
/// was written and whether the instance that wrote it has exited. Never the token itself.
pub fn explain_error(&self, error: &str) -> String {
let Some(src) = &self.file else { return error.to_string() };
if !error.starts_with("unauthorized") {
return error.to_string();
}
let written = src
.modified
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|t| crate::crash::utc(t.as_secs()))
.unwrap_or_else(|| "at an unknown time".into());
let age = src.modified.and_then(|t| t.elapsed().ok()).map(|d| format!(" ({} s ago)", d.as_secs())).unwrap_or_default();
let path = src.path.display();
match src.writer.filter(|(pid, start)| writer_exited(*pid, *start)) {
Some((pid, _)) => format!(
"{error}; stale token file from an instance that exited (process {pid}): {path}, written {written}{age}; delete that file or restart SolidCraft"
),
None => format!("{error}; token from {path}, written {written}{age}; if SolidCraft was force-quit, restart it or delete that file"),
}
}
}
/// Files bigger than this are not token files.
const MAX_TOKEN_FILE: u64 = 4096;
fn read_token_file(path: &Path) -> Option<ClientToken> {
let f = std::fs::File::open(path).ok()?;
let modified = f.metadata().ok().and_then(|m| m.modified().ok());
let mut text = String::new();
f.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
if text.len() as u64 > MAX_TOKEN_FILE {
return None;
}
let token = text.trim();
if token.is_empty() {
return None;
}
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let writer = read_pid_file(&pid_path(&path));
Some(ClientToken { token: token.to_string(), file: Some(TokenSource { path, modified, writer }) })
}
/// The process file beside the token file `path`: `7878.token` → `7878.pid`.
pub fn pid_path(path: &Path) -> PathBuf {
path.with_extension("pid")
}
/// `<pid> [<start time>]`; anything else (or no file) is no information.
fn read_pid_file(path: &Path) -> Option<(u32, Option<u64>)> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(256).read_to_string(&mut text).ok()?;
let mut fields = text.split_whitespace();
let pid = fields.next()?.parse::<u32>().ok().filter(|&pid| pid > 0)?;
Some((pid, fields.next().and_then(|s| s.parse().ok())))
}
/// Whether the process that wrote a token file is known to be gone: not running, or its id now
/// belongs to a process that started at another time. Unknown counts as running.
fn writer_exited(pid: u32, start: Option<u64>) -> bool {
process_running(pid) == Some(false) || start.zip(process_start(pid)).is_some_and(|(saved, now)| saved != now)
}
/// Whether process `pid` runs; `None` when that can't be found out.
fn process_running(pid: u32) -> Option<bool> {
#[cfg(target_os = "linux")]
{
if !Path::new("/proc/self").exists() {
return None;
}
match std::fs::metadata(format!("/proc/{pid}")) {
Ok(_) => Some(true),
Err(e) if e.kind() == ErrorKind::NotFound => Some(false),
Err(_) => None,
}
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().ok()?;
if output.status.success() {
return Some(true);
}
(output.status.code() == Some(1) && output.stdout.iter().all(u8::is_ascii_whitespace)).then_some(false)
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.creation_flags(0x0800_0000)
.output()
.ok()?;
if !output.status.success() || output.stdout.is_empty() {
return None;
}
// `"image","pid",…` per process; with no match, a message line without a pid.
let text = String::from_utf8_lossy(&output.stdout);
Some(text.lines().any(|line| line.split("\",\"").nth(1).and_then(|s| s.trim_matches('"').parse::<u32>().ok()) == Some(pid)))
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
None
}
}
/// Linux: the process's start time (clock ticks after boot), which tells a reused id apart.
fn process_start(pid: u32) -> Option<u64> {
#[cfg(target_os = "linux")]
{
let mut stat = String::new();
std::fs::File::open(format!("/proc/{pid}/stat")).ok()?.take(4096).read_to_string(&mut stat).ok()?;
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
None
}
}
/// `a == b`, taking the same time wherever they differ.
pub fn tokens_match(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file
/// is removed and no token is written. A file left by an earlier launch is replaced. The process
/// file beside it ([`pid_path`]) records this process, best effort.
pub fn write_token_file(path: &Path, token: &str) -> Result<()> {
#[cfg(windows)]
let restrict = restrict_to_user;
#[cfg(not(windows))]
let restrict = |_: &Path| Ok(());
write_token_file_with(path, token, restrict)
}
/// [`write_token_file`], with `restrict` limiting the new file to the user (Windows).
fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
std::fs::create_dir_all(dir)?;
let _ = std::fs::remove_file(path);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
opts.mode(0o600);
}
let opened = opts.open(path)?;
// On a refusal the file goes with the closure, so it is closed before it is removed.
let mut f = match restrict(path).map(|()| opened) {
Ok(f) => f,
Err(e) => {
let _ = std::fs::remove_file(path);
return Err(e);
}
};
f.write_all(token.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
let pid = std::process::id();
let line = process_start(pid).map_or_else(|| format!("{pid}\n"), |start| format!("{pid} {start}\n"));
let _ = std::fs::write(pid_path(path), line);
Ok(())
}
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
/// Another launch may have written the same file since (a second window on the same port,
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
/// has the same one, but each launch's token is its own.
pub fn remove_token_file(path: &Path, token: &str) {
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(pid_path(path));
}
}
/// The token a token file holds, trimmed; `None` when there is no readable token file.
fn read_token(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
let token = text.trim();
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
}
/// Whether the token file at `path` holds a token other than `token`: another launch's.
fn held_by_another(path: &Path, token: &str) -> bool {
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
}
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
/// is left suspended (security software holding it), and waiting on it without a limit froze
/// the app.
#[cfg(windows)]
const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
/// How a run of `icacls` ended.
#[cfg(any(windows, test))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum IcaclsEnd {
Succeeded,
Failed,
/// It didn't finish within [`ICACLS_LIMIT`] and was ended.
TimedOut,
}
/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is
/// in the folder `SOLIDCRAFT_CONTROL_DIR` names. A timeout is accepted only in the default folder,
/// the user's own local app data, which other users can't read by default; nothing is known about
/// the permissions of any other folder.
#[cfg(any(windows, test))]
fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> {
match end {
IcaclsEnd::Succeeded => Ok(()),
IcaclsEnd::TimedOut if !custom_folder => Ok(()),
IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))),
IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))),
}
}
/// Windows: drop inherited entries and grant only the current user full access. An `icacls`
/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means.
#[cfg(windows)]
fn restrict_to_user(path: &Path) -> Result<()> {
use std::os::windows::process::CommandExt;
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
let child = std::process::Command::new("icacls")
.arg(path)
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.creation_flags(0x0800_0000)
.spawn()?;
let end = match wait_within(child, ICACLS_LIMIT)? {
Some(status) if status.success() => IcaclsEnd::Succeeded,
Some(_) => IcaclsEnd::Failed,
None => IcaclsEnd::TimedOut,
};
restriction_result(end, custom_control_dir().is_some(), path)
}
/// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it
/// hasn't finished by then.
#[cfg(any(windows, test))]
fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Result<Option<std::process::ExitStatus>> {
let start = std::time::Instant::now();
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
if start.elapsed() >= limit {
// Not waited for afterwards: a suspended process may not end at once.
let _ = child.kill();
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Removes the token file (path, token) when the app ends, while it still holds that token.
pub struct TokenFile(pub PathBuf, pub String);
impl Drop for TokenFile {
fn drop(&mut self) {
remove_token_file(&self.0, &self.1);
}
}
/// SolidCraft's control channel: token files in `<data folder>/control` (`SOLIDCRAFT_CONTROL_DIR`
/// replaces it), `SOLIDCRAFT_CONTROL_TOKEN`, and 27878 first when the launch names no port.
pub const APP: App =
App { name: "SolidCraft", data_dir: crate::recovery::data_dir, env: solidcraft_geom::env::var_os, utc: crate::crash::utc, default_port: 27878 };
#[cfg(test)]
mod tests {
use super::*;
/// #6145: a child that doesn't finish in time is given up on, not waited for forever.
#[cfg(unix)]
#[test]
fn a_slow_child_is_waited_for_only_so_long() {
use std::time::{Duration, Instant};
let start = Instant::now();
let slow = std::process::Command::new("sleep").arg("30").spawn().unwrap();
assert!(wait_within(slow, Duration::from_millis(200)).unwrap().is_none());
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed());
let quick = std::process::Command::new("true").spawn().unwrap();
assert!(wait_within(quick, Duration::from_secs(5)).unwrap().is_some_and(|s| s.success()));
let failing = std::process::Command::new("false").spawn().unwrap();
assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success()));
}
/// #6145's freeze fix stays in the default folder, the user's own; a folder picked with
/// `SOLIDCRAFT_CONTROL_DIR` gets no token unless the file was really restricted.
#[test]
fn only_the_default_folder_accepts_an_icacls_that_timed_out() {
let path = Path::new("control/7878.token");
for custom in [false, true] {
assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok());
assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err());
}
assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok());
let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err();
assert_eq!(e.kind(), ErrorKind::TimedOut);
assert!(e.to_string().contains("7878.token"), "{e}");
}
/// A file that couldn't be restricted is removed, empty and with no process file beside it, and
/// the error goes to the caller. A restricted one gets its token.
#[test]
fn a_token_file_that_could_not_be_restricted_is_removed() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id()));
let path = dir.join("7879.token");
let e = write_token_file_with(&path, "secret-token", |p| {
assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in");
restriction_result(IcaclsEnd::TimedOut, true, p)
})
.unwrap_err();
assert_eq!(e.kind(), ErrorKind::TimedOut);
assert!(!path.exists() && !pid_path(&path).exists());
write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap();
assert_eq!(read_token(&path).as_deref(), Some("secret-token"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tokens_compare_whole() {
assert!(tokens_match("abc123", "abc123"));
assert!(!tokens_match("abc123", "abc124"));
assert!(!tokens_match("abc123", "abc12"));
assert!(!tokens_match("", "a"));
}
/// The preference starts the listener by default, off it doesn't, and a port given on the
/// command line or in the environment wins over it either way.
#[test]
fn the_preference_decides_unless_a_port_is_given() {
assert_eq!(plan(None, true), Some(Listen::Default));
assert_eq!(plan(None, false), None);
assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878)));
assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878)));
}
/// A taken port falls through to the next free one, on loopback only; a run of taken ports
/// is an error, not a hang.
#[test]
fn a_taken_port_falls_back_to_the_next_free_one() {
let first = bind_with_fallback(DEFAULT_PORT, PORT_TRIES).unwrap();
let a = first.local_addr().unwrap();
assert!(a.ip().is_loopback() && (DEFAULT_PORT..DEFAULT_PORT + PORT_TRIES).contains(&a.port()), "{a}");
let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap();
assert!(second.local_addr().unwrap().port() > a.port());
assert!(bind_with_fallback(a.port(), 1).is_err());
}
#[test]
fn the_discovery_file_holds_a_port() {
assert_eq!(parse_port("27879\n"), Some(27879));
assert_eq!(parse_port("0"), None);
assert_eq!(parse_port("70000"), None);
assert_eq!(parse_port("nope"), None);
let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id()));
let path = dir.join("current.port");
write_token_file(&path, "27881").unwrap();
assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881));
let _ = std::fs::remove_dir_all(&dir);
}
/// A one-line token file (and one with no process file) still reads.
#[test]
fn a_token_file_reads_with_and_without_its_process_file() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-read-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("7879.token");
std::fs::write(&path, "legacy-token\n").unwrap();
let client = read_token_file(&path).unwrap();
assert_eq!(client.token, "legacy-token");
let src = client.file.as_ref().unwrap();
assert_eq!(src.path, std::fs::canonicalize(&path).unwrap());
assert!(src.modified.is_some() && src.writer.is_none());
std::fs::write(pid_path(&path), "not a pid\n").unwrap();
assert!(read_token_file(&path).unwrap().file.unwrap().writer.is_none());
std::fs::write(&path, "x".repeat(4097)).unwrap();
assert!(read_token_file(&path).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
use solidcraft_control::auth::{ClientToken, TokenSource};
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
/// A refused token names its file and when it was written, never the token.
#[test]
fn unauthorized_error_names_the_token_file() {
let path = PathBuf::from("control/7878.token");
let modified = Some(UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000));
let client = ClientToken { token: "secret-token".into(), file: Some(TokenSource { path: path.clone(), modified, writer: None }) };
let client = ClientToken { token: "secret-token".into(), file: Some(TokenSource { path: path.clone(), modified, writer: None }), app: APP };
let error = client.explain_error("unauthorized: send the control token");
assert!(error.contains(&format!("token from {}, written 2023-11-14T22:13:20Z (", path.display())), "{error}");
assert!(error.contains("restart it or delete that file") && !error.contains("secret-token"), "{error}");
assert_eq!(client.explain_error("unknown method"), "unknown method");
let explicit = client_token(0, Some("explicit-token")).unwrap();
let explicit = APP.client_token(0, Some("explicit-token")).unwrap();
assert!(explicit.token == "explicit-token" && explicit.file.is_none());
assert_eq!(explicit.explain_error("unauthorized"), "unauthorized");
}
/// A process file naming a process that has exited marks the token file stale.
#[cfg(any(unix, windows))]
#[test]
fn a_token_file_from_an_exited_process_is_stale() {
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "control_auth::tests::tokens_compare_whole"])
.stdout(std::process::Stdio::null())
.spawn()
.unwrap();
let pid = child.id();
assert!(child.wait().unwrap().success());
let source = |writer| Some(TokenSource { path: PathBuf::from("7878.token"), modified: None, writer });
let stale = ClientToken { token: "secret-token".into(), file: source(Some((pid, None))) };
let error = stale.explain_error("unauthorized");
assert!(error.contains(&format!("stale token file from an instance that exited (process {pid}): 7878.token")), "{error}");
assert!(!error.contains("secret-token"));
let live = ClientToken { token: "t".into(), file: source(Some((std::process::id(), process_start(std::process::id())))) };
assert!(!live.explain_error("unauthorized").contains("stale"));
#[cfg(target_os = "linux")]
{
let reused = process_start(std::process::id()).map(|s| s + 1);
let reused = ClientToken { token: "t".into(), file: source(Some((std::process::id(), reused))) };
assert!(reused.explain_error("unauthorized").contains("stale token file"));
}
}
/// The token file is the user's alone, and a client reads it back.
#[cfg(unix)]
#[test]
fn the_token_file_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("control/7878.token");
std::fs::create_dir_all(dir.join("control")).unwrap();
std::fs::write(&path, "stale").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
let writer = read_token_file(&path).unwrap().file.unwrap().writer;
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
assert!(!path.exists() && !pid_path(&path).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
/// loopback) and write the same files. The one closing first leaves the other's token, process
/// and discovery files; the last one takes them all away, `current.pid` too.
#[test]
fn a_closing_launch_leaves_another_launchs_files() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
let (first, second) = ("a".repeat(64), "b".repeat(64));
// The first window, then the second on the same port, each writing both files.
for token in [&first, &second] {
write_token_file(&file, token).unwrap();
write_token_file(&current, "27878").unwrap();
}
// The first closes: the second's files stay.
remove_token_file(&file, &first);
remove_discovery(&current, &file, 27878, &first);
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
assert!(pid_path(&file).exists() && current.exists() && pid_path(&current).exists());
// Another port named in the discovery file is another app's too.
remove_discovery(&current, &file, 27879, &second);
assert!(current.exists());
// The second closes: nothing of either is left.
remove_token_file(&file, &format!(" {second}\n"));
remove_discovery(&current, &file, 27878, &second);
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(&current).exists());
// With no token file left (removed by hand, or never written), the discovery file of the
// port still goes; a missing file is no error.
write_token_file(&current, "27878").unwrap();
remove_token_file(&file, &second);
remove_discovery(&current, &file, 27878, &second);
assert!(!current.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+9 -30
View File
@@ -1,32 +1,9 @@
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
//! #1618): the app's control server and the bridged MCP server both say what the request still
//! does when it finishes: nothing (it only reads), write a file (an export or a save), or change
//! the design.
//! What SolidCraft's requests do when they outlast their wait (docs/control-protocol.md, #1618), as
//! a [`solidcraft_control::Effect`]: nothing (it only reads), write a file (an export or a save),
//! or change the design. The app's control server and the bridged MCP server both say it.
use serde_json::Value;
/// What a request still running does when it finishes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effect {
/// It only reads (the design, the window or the command list): nothing changes.
ReadsOnly,
/// It writes a file (an export, a save, a render to a path); the design stays as it is.
WritesFile,
/// It changes the design (or may: anything not known to read or only write a file).
Changes,
}
impl Effect {
/// What happens when the request finishes, for a message: "it only reads: nothing changes",
/// "it was not cancelled and will still write the file when it finishes", …
pub fn when_done(self) -> &'static str {
match self {
Effect::ReadsOnly => "it only reads: nothing changes",
Effect::WritesFile => "it was not cancelled and will still write the file when it finishes; the design does not change",
Effect::Changes => "it was not cancelled and will still apply when it finishes",
}
}
}
use solidcraft_control::Effect;
/// Control methods that only read: a timed-out one changes nothing when it finishes.
/// (`ui.screenshot` with a `path`, and `ui.render`, write a file: see [`effect`].)
@@ -163,8 +140,10 @@ mod tests {
assert_eq!(effect(m, &p), Effect::WritesFile, "{m} {p}");
assert!(!reads_only(m, &p), "{m} {p}");
}
assert!(Effect::WritesFile.when_done().contains("will still write the file") && !Effect::WritesFile.when_done().contains("apply"));
assert!(Effect::Changes.when_done().contains("will still apply"));
assert!(Effect::ReadsOnly.when_done().contains("nothing changes"));
assert!(
Effect::WritesFile.when_done("design").contains("will still write the file") && !Effect::WritesFile.when_done("design").contains("apply")
);
assert!(Effect::Changes.when_done("design").contains("will still apply"));
assert!(Effect::ReadsOnly.when_done("design").contains("nothing changes"));
}
}
+33 -16
View File
@@ -72,7 +72,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"solid.thread" => &[("face", Pick), ("from", Pick), ("up_to", Plane)],
"solid.emboss" => &[("face", Make)],
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
"solid.press_pull" => &[("face", Pick), ("edges", Picks), ("body", Body)],
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
// Align finds its picks where they are shown and takes both ends into the moved bodies'
// frame itself: a pick mapped into the frame of the body under it would put the two ends
@@ -227,11 +227,11 @@ impl Mapper<'_> {
let m = if self.here { self.f.sketch } else { under.as_ref().map(|(m, _)| *m).unwrap_or(self.f.active) };
self.first.get_or_insert(m);
let out = self.local_point(&m, p);
// The command sees the pick in the frame of the body under it. Remember the bodies
// whose faces meet there, so a face of that body is found and a body that only
// shares its place in the model does not make the pick ambiguous (#419).
if let Some((_, names)) = &under {
remember(out.as_ref().and_then(vec3).unwrap_or(p), names.clone());
// The command sees the pick in the frame of the body under it. Remember where it was
// picked and the bodies whose faces meet there, so a face of that body is found and a
// body that only shares its place in the model does not make the pick ambiguous (#419).
if !self.here {
remember(out.as_ref().and_then(vec3).unwrap_or(p), p, under.map(|(_, names)| names).unwrap_or_default());
}
out
}
@@ -361,9 +361,19 @@ thread_local! {
/// The outermost command is `solid.move`, whose parameters stay in the world when its bodies
/// lie in differently turned frames (`split_move`).
static OUTER_MOVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// The picks of the running command: the point it sees and the bodies whose faces meet
/// there (the first is the body under it; more than one means the pick is ambiguous).
static PICKS: std::cell::RefCell<Vec<(Vec3, Vec<String>)>> = const { std::cell::RefCell::new(Vec::new()) };
/// The picks of the running command.
static PICKS: std::cell::RefCell<Vec<Pick>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// A pick of the running command.
struct Pick {
/// The point the command sees.
local: Vec3,
/// Where it was picked.
world: Vec3,
/// The bodies whose faces meet there: the first is the body under it, more than one means
/// the pick is ambiguous, and none that no face is under it.
bodies: Vec<String>,
}
/// How many picks of one command are remembered (a command takes few).
@@ -371,21 +381,28 @@ const MAX_PICKS: usize = 4096;
/// How many bodies may meet at one pick (an ambiguous pick names them).
const MAX_PICK_BODIES: usize = 64;
/// Remember the bodies whose faces meet at a pick, against the point the command sees.
fn remember(point: Vec3, names: Vec<String>) {
let names: Vec<String> = names.into_iter().take(MAX_PICK_BODIES).collect();
/// Remember where a pick was made and the bodies whose faces meet there, against the point the
/// command sees.
fn remember(local: Vec3, world: Vec3, names: Vec<String>) {
let bodies: Vec<String> = names.into_iter().take(MAX_PICK_BODIES).collect();
PICKS.with(|picks| {
let mut picks = picks.borrow_mut();
if picks.len() < MAX_PICKS {
picks.push((point, names));
picks.push(Pick { local, world, bodies });
}
});
}
/// The bodies whose faces meet at `point`, when the running command picked it: the first is
/// the body under the pick, and more than one means the pick is ambiguous.
/// The bodies whose faces meet at `point`, when the running command picked it on a face: the
/// first is the body under the pick, and more than one means the pick is ambiguous.
pub(crate) fn picked(point: Vec3) -> Option<Vec<String>> {
PICKS.with(|picks| picks.borrow().iter().find(|(p, _)| *p == point).map(|(_, names)| names.clone()))
PICKS.with(|picks| picks.borrow().iter().find(|p| p.local == point && !p.bodies.is_empty()).map(|p| p.bodies.clone()))
}
/// Where the running command's `nth` pick seen at `point` was picked (counting from 0: two picks
/// on parts placed apart can be the same point in their own frames).
pub(crate) fn picked_world(point: Vec3, nth: usize) -> Option<Vec3> {
PICKS.with(|picks| picks.borrow().iter().filter(|p| p.local == point).nth(nth).map(|p| p.world))
}
/// Forget the picks of the last command: a new one starts with none.
+396
View File
@@ -611,3 +611,399 @@ fn a_combine_tool_stays_where_it_is_placed() {
assert_eq!(lumps(&s), Some(2), "the boss stays where it is in the world");
assert!((volume(&mut s) - 17000.0).abs() < 1e-6, "{}", volume(&mut s));
}
/// The volume of one body in its own frame (a component's body once, however often it is placed).
fn body_volume(s: &Session, body: &str) -> f64 {
s.model.state().body(body).unwrap_or_else(|| panic!("no body {body}")).measure().unwrap().volume
}
/// The body the last feature, a fillet or chamfer, keeps.
fn blend_body(s: &Session) -> Option<String> {
match &s.doc.features.last().unwrap().kind {
solidcraft_doc::FeatureKind::Fillet { body, .. } | solidcraft_doc::FeatureKind::Chamfer { body, .. } => body.as_ref().map(|b| b.0.clone()),
k => panic!("{k:?}"),
}
}
/// The points the last feature, a fillet or chamfer, keeps.
fn blend_edges(s: &Session) -> Vec<Vec3> {
match &s.doc.features.last().unwrap().kind {
solidcraft_doc::FeatureKind::Fillet { edges, .. } | solidcraft_doc::FeatureKind::Chamfer { edges, .. } => edges.iter().map(|p| p.0).collect(),
k => panic!("{k:?}"),
}
}
fn refused(s: &mut Session, id: &str, p: Value) -> String {
let features = s.doc.features.len();
let err = s.execute(id, &p).expect_err(&format!("{id} {p}")).to_string();
assert_eq!(s.doc.features.len(), features, "{id} {p}: {err}");
err
}
/// A 1 mm chamfer of a 40 mm edge between square faces.
const CUT: f64 = 20.0;
/// A box 40 x 30 x 10.
const BLOCK: f64 = 12000.0;
/// Component Base holds "Base block" and component Cap "Cap block", each a 40 x 30 x 10 box,
/// Cap's at `cap_z` in its own frame and its placement moved by `moved`; Base is active.
fn two_parts(cap_z: f64, moved: [f64; 3]) -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Base"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Base block"}));
run(&mut s, "component.create", json!({"name": "Cap", "parent": "root"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "corner": [0, 0, cap_z], "body_name": "Cap block"}));
if moved != [0.0; 3] {
run(&mut s, "occurrence.move", json!({"occurrence": "Cap:1", "translate": moved}));
}
run(&mut s, "component.activate", json!({"component": "Base"}));
s
}
/// Component Lower holds "Lower block" (40 x 30 x 10) and component Upper "Upper block" (40 x 30
/// x 2), both from their frames' origins, so Upper's bottom edges lie on Lower's in their own
/// frames. Upper:1 sits on Lower, and a second placement, Upper:2, under it. The root is active.
fn lower_and_uppers() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Lower"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Lower block"}));
run(&mut s, "component.create", json!({"name": "Upper", "parent": "root"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 2, "body_name": "Upper block"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Upper:1", "translate": [0, 0, 10]}));
let under = run(&mut s, "occurrence.copy", json!({"component": "Upper"}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": under, "space": "world", "translate": [0, 0, -12]}));
let (lo, hi) = world_bbox(&s, "Upper block (Upper:2)");
assert!(near(lo, Vec3::new(0.0, 0.0, -2.0)) && near(hi, Vec3::new(40.0, 30.0, 0.0)), "{lo:?} {hi:?}");
(s, under)
}
/// A world pick on an edge of a moved part finds that part, though another part has an edge at
/// the same point in its own frame, and the blend keeps the body it found.
#[test]
fn edge_picks_on_a_moved_part_find_it_past_other_parts_frames() {
let round = (1.0 - std::f64::consts::PI / 4.0) * 40.0;
for (case, mut s, id, p, body, base, cap) in [
("v0", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
("v4", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1}), "Cap block", BLOCK, BLOCK - CUT),
("v5", two_parts(0.0, [500.0, 0.0, 0.0]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
(
"v6",
two_parts(0.0, [0.0, 0.0, 50.0]),
"solid.chamfer",
json!({"edges": [[20, 0, 0]], "distance": 1, "tangent_chain": false}),
"Base block",
BLOCK - CUT,
BLOCK,
),
("fillet", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.fillet", json!({"edges": [[20, 0, 0]], "radius": 1}), "Base block", BLOCK - round, BLOCK),
(
"press_pull",
two_parts(0.0, [0.0, 0.0, 50.0]),
"solid.press_pull",
json!({"edges": [[20, 0, 0]], "distance": 1}),
"Base block",
BLOCK - round,
BLOCK,
),
("v1 unplaced", two_parts(50.0, [0.0; 3]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
] {
if case == "v5" {
run(&mut s, "component.activate", json!({"component": "root"}));
}
let features = s.doc.features.len();
run(&mut s, id, p.clone());
assert_eq!(s.doc.features.len(), features + 1, "{case}");
assert_eq!(blend_body(&s).as_deref(), Some(body), "{case}");
let (b, c) = (body_volume(&s, "Base block"), body_volume(&s, "Cap block"));
assert!((b - base).abs() < 1e-3 * CUT && (c - cap).abs() < 1e-3 * CUT, "{case}: Base {b}, Cap {c}");
}
// The pick on Cap is kept in Cap's frame.
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1}));
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
}
/// Where the edges of two parts lie in one place in the world, a pick without `body` is refused,
/// naming both; with `body`, or a pick's own `body`, the named part is cut.
#[test]
fn edge_picks_where_two_parts_meet_name_both() {
for (case, cap_z, moved) in [("v2 unplaced", 0.0, [0.0; 3]), ("v3 moved", 50.0, [0.0, 0.0, -50.0])] {
for (id, size) in [("solid.chamfer", "distance"), ("solid.fillet", "radius")] {
let mut s = two_parts(cap_z, moved);
let err = refused(&mut s, id, json!({"edges": [[20, 0, 0]], size: 1}));
assert!(
err.contains("[20, 0, 0] lies on edges of 2 bodies, Base block and Cap block") && err.contains("with `body`"),
"{case} {id}: {err}"
);
assert!(!err.contains("hidden"), "{err}");
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
}
let mut s = two_parts(cap_z, moved);
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Cap block"}));
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK, "{case}");
let mut s = two_parts(cap_z, moved);
run(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 0], "body": "Cap block"}], "distance": 1}));
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK, "{case}");
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"), "{case}");
}
}
/// The lower block's outer bottom edge, with a hidden upper block placed under it: that block's
/// top edge lies there too, so the pick is refused and the message says it is hidden. A lower
/// block's edge whose only twin is in the upper block's own frame is cut.
#[test]
fn edge_picks_beside_a_hidden_placement_name_it() {
let (mut s, under) = lower_and_uppers();
run(&mut s, "browser.visibility", json!({"visible": false, "paths": [[under]]}));
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!(
err.contains("lies on edges of 2 bodies, Lower block and Upper block (Upper:2)") && err.contains("(Upper block (Upper:2) is hidden)"),
"{err}"
);
run(&mut s, "browser.visibility", json!({"visible": true, "paths": [[under]]}));
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!(err.contains("Lower block and Upper block (Upper:2)") && !err.contains("hidden"), "{err}");
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Lower block"}));
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
// Upper:2 out of the way: the lower block's bottom left edge has a twin only in Upper's frame.
let (mut s, under) = lower_and_uppers();
run(&mut s, "occurrence.move", json!({"occurrence": under, "space": "world", "translate": [0, 0, -100]}));
run(&mut s, "solid.chamfer", json!({"edges": [[0, 15, 0]], "distance": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
assert!((body_volume(&s, "Lower block") - (BLOCK - 15.0)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
}
/// The lower block's top edge, where the upper block on it has its bottom edges, is refused
/// naming both; `body` cuts either. A second lower block placed on the upper one meets it at the
/// upper block's top edges likewise.
#[test]
fn edge_picks_on_a_top_edge_under_another_part_name_both() {
let (mut s, _) = lower_and_uppers();
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1}));
assert!(err.contains("[20, 0, 10] lies on edges of 2 bodies, Lower block and Upper block:"), "{err}");
for (body, lower, upper) in [("Lower block", BLOCK - CUT, 2400.0), ("Upper block", BLOCK, 2400.0 - CUT)] {
let (mut s, _) = lower_and_uppers();
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1, "body": body}));
let (b, c) = (body_volume(&s, "Lower block"), body_volume(&s, "Upper block"));
assert!((b - lower).abs() < 1e-3 * CUT && (c - upper).abs() < 1e-3 * CUT, "{body}: Lower {b}, Upper {c}");
}
let (mut s, _) = lower_and_uppers();
let stacked = run(&mut s, "occurrence.copy", json!({"component": "Lower"}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": stacked, "space": "world", "translate": [0, 0, 12]}));
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 12]], "distance": 1}));
assert!(err.contains("[20, 0, 12] lies on edges of 2 bodies, Upper block and Lower block (Lower:2):"), "{err}");
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 12]], "distance": 1, "body": "Lower block (Lower:2)"}));
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
}
/// A block's top edge inside the bottom face of a plate placed on it: the block alone has an edge
/// there.
#[test]
fn an_edge_inside_another_parts_face_is_picked() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Lower"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Lower block"}));
run(&mut s, "component.create", json!({"name": "Plate", "parent": "root"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 2, "corner": [-30, -35, 0], "body_name": "Plate block"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Plate:1", "translate": [0, 0, 10]}));
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Plate block") == 20000.0);
}
/// Round edges of two parts in one place: a pick a little inside them, as a click on the drawn
/// edge lands, is refused naming both. A disc edge 0.2 mm out is another edge.
#[test]
fn coincident_round_edges_of_two_parts_are_refused() {
let parts = |b_radius: f64| {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "A"}));
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 10, "body_name": "A rod"}));
run(&mut s, "component.create", json!({"name": "B", "parent": "root"}));
run(&mut s, "solid.cylinder", json!({"radius": b_radius, "height": 2, "body_name": "B disc"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "B:1", "translate": [0, 0, 10]}));
s
};
let pi = std::f64::consts::PI;
let disc = |r: f64| pi * r * r * 2.0;
// A round of radius 1 on the rod's top edge, radius 10: its section's area at its centroid's
// radius.
let (a, c) = (1.0 - pi / 4.0, (10.0 - 3.0 * pi) / (3.0 * (4.0 - pi)));
let rounded = pi * 100.0 * 10.0 - a * 2.0 * pi * (10.0 - c);
let mut s = parts(10.0);
let err = refused(&mut s, "solid.fillet", json!({"edges": [[0, 9.98, 10]], "radius": 1}));
assert!(err.contains("lies on edges of 2 bodies, A rod and B disc"), "{err}");
for pick in [[0.0, 9.98, 10.0], [0.0, 10.0, 10.0]] {
let mut s = parts(10.2);
run(&mut s, "solid.fillet", json!({"edges": [pick], "radius": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("A rod"), "{pick:?}");
let (v, b) = (body_volume(&s, "A rod"), body_volume(&s, "B disc"));
assert!((v - rounded).abs() < 2e-4 * rounded && (b - disc(10.2)).abs() < 1e-6 * b, "{pick:?}: {v} vs {rounded}, {b}");
}
}
/// Two picks at one point of their parts' frames, on two parts, in one command: each is looked
/// up where it was picked, and as the feature keeps one body or none, it is refused.
#[test]
fn two_picks_at_one_point_of_two_parts_frames_are_refused() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0], [20, 0, 50]], "distance": 1}));
assert!(err.contains("the picks lie on 2 bodies, Base block and Cap block") && err.contains("in a command of its own"), "{err}");
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
}
/// One part placed twice: its edge picked through both placements, or through two placements in
/// one place, is one edge of one body.
#[test]
fn one_edge_of_two_placements_is_one_edge() {
for (offset, edges) in [
([0.0, 40.0, 0.0], json!([[20, 0, 0], [20, 40, 0]])),
([0.0; 3], json!([[20, 0, 0]])),
// Overlapping along the edge: the pick is at x 25 of one placement and x 15 of the other.
([10.0, 0.0, 0.0], json!([[25, 0, 0]])),
] {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Pin"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Pin block"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let copy = run(&mut s, "occurrence.copy", json!({"component": "Pin"}))["occurrence"].as_u64().unwrap();
if offset != [0.0; 3] {
run(&mut s, "occurrence.move", json!({"occurrence": copy, "space": "world", "translate": offset}));
}
run(&mut s, "solid.chamfer", json!({"edges": edges, "distance": 1}));
assert!((body_volume(&s, "Pin block") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{offset:?}: {}", body_volume(&s, "Pin block"));
assert!((volume(&mut s) - 2.0 * (BLOCK - CUT)).abs() < 2e-3 * CUT, "{offset:?}");
assert_eq!(blend_body(&s), None, "one body in the model");
}
}
/// An edge given by body and index keeps its body on the feature.
#[test]
fn an_edge_by_index_keeps_its_body() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
let tol = 1e-3_f64.max(s.model.state().body("Base block").unwrap().body.size() * 2e-3);
let edges = s.model.state().body("Base block").unwrap().body.edges(tol).unwrap();
let index = edges.iter().position(|e| e.mid.dist(Vec3::new(20.0, 0.0, 0.0)) < 1e-6).unwrap();
run(&mut s, "solid.chamfer", json!({"edges": [{"body": "Base block", "index": index}], "distance": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("Base block"));
assert!((body_volume(&s, "Base block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Cap block") == BLOCK);
}
/// A pick 0.42 mm off a moved part's edge, beyond the faces the placement is found by, is still
/// looked up where it was picked: another part's edge there in its own frame is not in the way.
#[test]
fn a_pick_beside_an_edge_is_looked_up_where_it_was_picked() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
run(&mut s, "solid.chamfer", json!({"edges": [[20, -0.3, -0.3]], "distance": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("Base block"));
assert!((body_volume(&s, "Base block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Cap block") == BLOCK);
}
/// A chamfer of a moved part, found by a world pick, stays on that part through an edit of
/// the other part, a recompute, undo and redo, and Edit Feature.
#[test]
fn a_blend_found_by_a_world_pick_keeps_its_part() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1, "name": "Edge"}));
let check = |s: &Session, base: f64, cap: f64, step: &str| {
let (b, c) = (body_volume(s, "Base block"), body_volume(s, "Cap block"));
assert!((b - base).abs() < 1e-3 * CUT && (c - cap).abs() < 1e-3 * CUT, "{step}: Base {b}, Cap {c}");
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{step}: {:?}", s.model.results);
};
check(&s, BLOCK, BLOCK - CUT, "made");
run(&mut s, "timeline.edit", json!({"feature": "Box1", "set": {"length": "50"}}));
check(&s, 15000.0, BLOCK - CUT, "Base edited");
run(&mut s, "timeline.compute_all", json!({}));
check(&s, 15000.0, BLOCK - CUT, "computed");
run(&mut s, "edit.undo", json!({}));
check(&s, BLOCK, BLOCK - CUT, "undone");
run(&mut s, "edit.redo", json!({}));
check(&s, 15000.0, BLOCK - CUT, "redone");
run(&mut s, "timeline.redefine", json!({"feature": "Edge", "command": "solid.chamfer", "params": {"edges": [[20, 0, 50]], "distance": 2}}));
check(&s, 15000.0, BLOCK - 4.0 * CUT, "redefined");
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"));
}
/// A corner of a moved part is refused at the point picked, in the world.
#[test]
fn a_corner_of_a_moved_part_is_refused_where_it_was_picked() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[0, 0, 50]], "distance": 1}));
assert!(err.contains("[0, 0, 50] is where 3 edges meet"), "{err}");
}
/// Picks far out, or overflowing a placement, in a design with moved parts are refused.
#[test]
fn hostile_edge_picks_in_a_placed_design_are_refused() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
for pick in [json!([1e300, 0, 0]), json!([1.7e308, -1.7e308, 1.7e308]), json!([[1e300, 0, 0]])] {
let err = s.execute("solid.chamfer", &json!({"edges": [pick], "distance": 1})).expect_err("refused");
assert!(matches!(err, crate::EngineError::BadParams { .. }), "{pick}: {err}");
}
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
}
/// The index of the edge of `body` whose midpoint is `mid`.
fn edge_index(s: &Session, body: &str, mid: Vec3) -> usize {
let b = s.model.state().body(body).unwrap().body.clone();
let edges = b.edges(1e-3_f64.max(b.size() * 2e-3)).unwrap();
edges.iter().position(|e| e.mid.dist(mid) < 1e-6).unwrap()
}
/// A pick's own `body` must exist and agree with the feature's `body`: the feature keeps one
/// body, and the pick is found on its own.
#[test]
fn a_picks_own_body_must_agree_with_body() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
let index = edge_index(&s, "Cap block", Vec3::new(20.0, 0.0, 0.0));
for edge in [json!({"point": [20, 0, 50], "body": "Cap block"}), json!({"body": "Cap block", "index": index})] {
let err = refused(&mut s, "solid.chamfer", json!({"edges": [edge], "body": "Base block", "distance": 1}));
assert!(err.contains("an edge names `Cap block`, but `body` is `Base block`: give one"), "{edge}: {err}");
}
let err = refused(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 0], "body": "Nobody"}], "distance": 1}));
assert!(err.contains("no body `Nobody`"), "{err}");
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
run(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 50], "body": "Cap block"}], "body": "Cap block", "distance": 1}));
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK);
}
/// Press Pull on an edge is a fillet: where two parts meet it is refused naming both, and
/// `body`, by its own name or a placement's, rounds that part.
#[test]
fn press_pull_on_an_edge_where_two_parts_meet_takes_body() {
let round = (1.0 - std::f64::consts::PI / 4.0) * 40.0;
let mut s = two_parts(50.0, [0.0, 0.0, -50.0]);
let err = refused(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!(err.contains("[20, 0, 0] lies on edges of 2 bodies, Base block and Cap block: name the one to round with `body`"), "{err}");
run(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Cap block"}));
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"));
assert!((body_volume(&s, "Cap block") - (BLOCK - round)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK);
let (mut s, _) = lower_and_uppers();
let stacked = run(&mut s, "occurrence.copy", json!({"component": "Lower"}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": stacked, "space": "world", "translate": [0, 0, 12]}));
run(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 12]], "distance": 1, "body": "Lower block (Lower:2)"}));
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
assert!((body_volume(&s, "Lower block") - (BLOCK - round)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
}
/// A body offset squashed flat, as only a hostile file has, leaves no placement to look a pick
/// up through: the pick is looked up in the body's own frame, so one far from every edge is
/// refused and one on an edge is cut.
#[test]
fn edge_picks_with_no_placement_to_look_through_are_checked() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Box"}));
let mut flat = solidcraft_doc::IDENTITY;
flat[2][2] = 0.0;
s.doc_mut().body_offsets.insert("Box".into(), flat);
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 15, 10]], "distance": 1}));
assert!(err.contains("no edge at [20, 15, 10]"), "{err}");
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!((body_volume(&s, "Box") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{}", body_volume(&s, "Box"));
}
+24 -10
View File
@@ -519,6 +519,10 @@ pub struct Session {
pub(crate) followed: Option<cmd::inspect::Followed>,
/// Project a face's edges into a sketch created on it (the default; `sketch.auto_project`).
pub auto_project: bool,
/// Project, Intersect, Include 3D Geometry and the face edges projected into a new sketch
/// make construction geometry rather than normal geometry (off by default; #13142). A
/// preference, kept like `auto_project` when another design is opened or started.
pub project_construction: bool,
/// How calls that break a command's parameter contract are treated (off by default). It
/// belongs to the session, not the design, so it survives opening and starting designs.
pub param_check: schema::ParamCheck,
@@ -827,6 +831,7 @@ impl Session {
document_id: cmd::inspect::new_document_id(),
followed: None,
auto_project: true,
project_construction: false,
param_check: schema::ParamCheck::Off,
easyprint_upload: None,
live_links: None,
@@ -890,6 +895,7 @@ impl Session {
self.stop_computing();
}
let auto_project = self.auto_project;
let project_construction = self.project_construction;
let validation = self.validation();
let single_surface_analysis = self.single_surface_analysis;
let param_check = self.param_check;
@@ -907,6 +913,7 @@ impl Session {
self.crash_journal = crash_journal;
self.command_check = command_check;
self.auto_project = auto_project;
self.project_construction = project_construction;
// The user's, like `auto_project`: the commands that open a design evaluate it under it
// already (`Session::opened_validating`).
self.set_validation(validation);
@@ -1945,6 +1952,7 @@ impl Session {
document_id: self.document_id.clone(),
followed: None,
auto_project: self.auto_project,
project_construction: self.project_construction,
param_check: self.param_check,
easyprint_upload: self.easyprint_upload.clone(),
live_links: None,
@@ -2321,16 +2329,7 @@ impl Session {
}
fn run_script_with(&mut self, script: &Value, profile: Option<bool>, each: &mut dyn FnMut(usize, &Value)) -> Result<(Vec<Value>, Option<Value>)> {
let list = match script {
Value::Array(a) => a.clone(),
Value::Object(o) => {
o.get("commands").and_then(Value::as_array).cloned().ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
}
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
};
if list.len() > 100_000 {
return Err(EngineError::Other("script too long".into()));
}
let list = script_steps(script)?;
let mut out = Vec::new();
let mut steps = Vec::new();
let clock = timing::Clock::start();
@@ -2358,6 +2357,21 @@ impl Session {
}
}
/// A script's steps: the JSON array itself, or the `commands` array of an object; at most 100 000.
pub fn script_steps(script: &Value) -> Result<&Vec<Value>> {
let list = match script {
Value::Array(a) => a,
Value::Object(o) => {
o.get("commands").and_then(Value::as_array).ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
}
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
};
if list.len() > 100_000 {
return Err(EngineError::Other("script too long".into()));
}
Ok(list)
}
#[cfg(test)]
mod boolean_regression_tests;
#[cfg(test)]

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