Compare commits

...

64 Commits

Author SHA1 Message Date
Brennen Herbruck 785ab93f16 Merge origin/main into moved/solidcore-2969
kernel_cases manifest.json: both sides added booleans cases (this branch the
2927 bore-step case and its chain, main one more); the counts are the sum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:37:45 -05:00
github-actions[bot] a77b7ab2e4 Regenerate generated files and fold pending files [skip ci] 2026-11-09 13:00:08 +00:00
selune ed2bafe7e3 Render: quality controls, progressive previews and RAM refresh (#12546)
Expose 16,384 spp, 8K UHD with X2 supersampling, CPU workers and persistent
denoising controls. Preserve complete-pass previews and bounded paged GPU
storage, with compatibility choices for changed rendering defaults.

Refresh native RAM observations after active allocation work ends while
retained images and metadata keep their software leases. Replay the complete
render changes onto current main and retain its integration contracts.

Give the existing GPU coat test a separate cold-pipeline preparation budget
while keeping a fixed 60-second deadline after preparation. Label all five
variants and report full job status on failure; keep their fixtures and
image/history assertions unchanged. Production timeouts and CI policy stay
unchanged.
2026-11-09 12:15:07 +00:00
jyviko ef966d46a1 Agent chat panel: a component over plain state that records what the user asked (#14124)
* Agent chat: the panel publishes into the widget registry directly (#12948)

panel.rs, settings.rs and the tests call solidcraft_ui_registry for
handles and rects instead of the crate::scenario re-exports, so the
panel does not reach the app's scenario module; tests keep the Harness.

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

* Agent chat: the panel's place and fold are its own AgentPanelPrefs (#12948)

The panel draws from AgentPanelPrefs (side, width, folded, in its own
window, the window's place) instead of reading SolidCraft's Preferences
fields: show copies them in, the panel changes the copy, and show
stores what changed (a side it did not move keeps its stored spelling).
toggle_panel shows, unfolds and hides on the panel's state alone; toggle
keeps the shared panel host's layout path and falls back to it. The
shared host's content reads the same copy, and owns_keyboard_input,
left_inset and tool_space read it too. In its own window the toggle key
works on the copy being drawn from, so it is not overwritten when the
copy is stored.

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

* Agent chat: the panel records what was asked as PanelActions (#12948)

Sending, connecting, new chat, mode and model choices, picking,
attaching, answering a permission and the rest no longer run while the
panel draws: the panel pushes a PanelAction onto the chat's queue and
the host runs the queue (panel::run) after the panel: show after the
frame's panel, shared_content after the shared host's body, and
prepare_shared right after the New chat shortcut. Test: the send button
records the message and sends nothing itself.

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

* Agent chat: the chat's own queries are AgentChat methods (#12948)

Whether New chat needs confirming, the models and their providers, the
provider the picker opens on, the grouped model list and whether a typed
model is accepted read only the conversation, so AgentChat answers them;
the functions taking the app delegate. The panel can then ask the chat
without the app.

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

* Agent chat: the panel draws from the chat and a PanelView, with no app (#12948)

panel::draw (the panel with its header) and panel::draw_content (the
controls and conversation in the shared panel host's body) take the
conversation (AgentChat), the panel's own prefs and a PanelView: the
plain data SolidCraft reads before drawing (layout, history bar,
document name and units, agents and the permission mode, selection
count, dialog open, turn limit, model warning, toggle keys, reading
direction, whether the shared host is measuring the content and the
composer height it measured). The flow stays an argument.

What the panel changes goes back as PanelActions, now also the
permission mode and a pasted picture (paste::handle returns the
action). What the shared host measures (the composer's height, the
height the content needs, the text fields that take the keyboard) is
kept in a ContentReport and handed to the host after drawing, in the
order it was drawn and only for the host's own layer, as before. show()
and shared_content() build the view, draw, store the prefs, hand over
the report and run the actions.

The conversation's items still hold the engine's Arc<Document> (marks
which messages changed the design); that is a follow-up.

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

* Agent chat: the desktop-only panel actions build only on the desktop (#12948)

Refresh, Connect, PastePicture and AttachPicture run discovery, agents
and the clipboard reader, which the web build does not have; their
variants and match arms are gated as those calls were, and every place
that records them already is. The wasm32 build compiles again.

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

* Agent chat: the host looks for agents before the panel's first frame (#12948)

Opening the chat ran discovery before drawing, so the first frame said
the agents were being looked for; recording it for after the frame drew
one frame of "None found" (or the setup card) first. show() starts
discovery before drawing again, as prepare_shared does for the shared
host, and the panel no longer records a Refresh of its own. Test: the
first frame after opening shows the detecting state.

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

* Agent chat: the host reads the panel's view only when the panel is drawn (#12948)

PanelView::of (selection count, agent list, keys, tooltip, model
warning) ran every frame, also while the chat was off or closed. The
start of the panel's frame (forgetting what is not drawn, the New chat
shortcut) is now panel::begin, which takes whether AI features are on
and says whether the panel is drawn; show() reads the view only then,
and shared_content() only for a shown, open chat, as main read them
only while drawing.

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

* Agent chat: main's preset test reads the widget registry directly (#12948)

The Gemini preset test (#12427) landed on main after the batch and reached the registry
through `scenario::`, which the registry commit replaced in this module.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:55:22 +00:00
jyviko 8babea5c63 solidcore-tess: re-export the evaluator types and vector conversions its plans expose (#14119)
* solidcore-tess: re-export the evaluator types and vector conversions its plans expose

TessPlan's public `surfaces` are solidcore::eval::EvalSurface values built with
solidcore_convert's vector conversions, so a client that builds or reads a plan had to name the
kernel crate and solidcore-convert, which the solidcore seam check refuses outside the public
crates. solidcore-tess now re-exports EvalCurve, EvalSurface, vec3 and record_vec3; a test
builds and reads a plan's surfaces through those names alone.

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

* solidcore-tess: format the re-export test with solidcore's rustfmt settings

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:47:16 -05:00
bherbruck f9ed9a90b4 Kernel sync (#13985)
Merges the kernel branch at 8ffb97d541.
2026-11-09 02:51:10 -05:00
Ahmad Khan b653486d10 Assistant: Add Agent offers a Google Gemini preset for OpenAI-compatible endpoints (#11816) (#12427)
* claim: #11816

* Test: Add Agent offers a Google Gemini OpenAI-compatible preset (#11816)

* Assistant: Add Agent offers a Google Gemini preset for OpenAI-compatible endpoints (#11816)

Google's OpenAI-compatible endpoint is
https://generativelanguage.googleapis.com/v1beta/openai; requests go to
{base}/chat/completions as for the other presets. DeepSeek, also asked for
in the issue, has been a preset since #5063.

* Test: the Add Agent dialog test scrolls the preset list to reach OpenRouter (#11816)

With the Google Gemini preset the list is taller than the popup, so OpenRouter sat below
its visible end and the click landed on nothing. The test now scrolls the list first.

---------

Co-authored-by: Ahmad Khan <ahmad.khan@shiritai.co>
2026-11-09 06:36:09 +00:00
jyviko 40ca4ce08c Ribbon and app bar: components over plain data that return what the user chose (#14006)
* Ribbon: the panel row's fit and label layout move to a ribbon module (#12939)

fit, Plan, PanelSize and ButtonSize, the row's dimensions and the button
label layout (lines, widths, CJK breaks, galleys) move unchanged from
toolbar.rs to the new pub module ribbon, with their tests; toolbar.rs uses
them from there. No behaviour changes.

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

* Ribbon: the panel row is a component over plain group data (#12939)

ribbon::show draws groups of buttons, gallery cells, collapsed groups,
group labels and the » menu from plain data (Row, Group, Button, MoreRow)
and returns what was chosen (Run with its place, Menu, Section, Open) with
each drawn button's response for the app's own part. Handles come from the
app (Handles), so SolidCraft's names are unchanged. SolidCraft builds the
data from its panels and keeps tooltips, drags, persistent paint, family
menus and the panel drop-downs, now run from the returned actions; its own
copies of the row's text, chevron and reveal painters are gone.
Tested with no app: a click returns its command and rect, a disabled
button and a disabled » row return nothing, a label asks for its menu,
and a group that does not fit is in the » menu, whose row runs.

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

* Ribbon: the tab row is a component over plain tab data (#12939)

ribbon::tabs draws the tabs (each with its own accent and contextual
flag), the + button and the buttons after the search field from plain
data, and returns Select (with whether the tab was already selected),
Menu, Add and Run; it gives back where the search field goes. Handles
and tab aliases come from the app, so SolidCraft's names are unchanged;
the visible-tab state is keyed by the component's scope. SolidCraft
builds the tabs and its Render job buttons and runs the actions; its tab
style moves to ribbon::tab_style.
Tested with no app: a tab click (selected or not), a right-click, the +
button, a ready and a not-ready button, and an alias.

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

* Ribbon: a group's menu rows and icon grid are components over plain entries (#12939)

ribbon::menu_row, ribbon::grid and ribbon::grid_cell draw a group menu's
rows and its grid of icons from plain data (MenuEntry, MenuStyle) and
return what was chosen (Run, Menu, TogglePin) with each entry's response
for the app's own part. The grid layout, arrow keys, pin delay, tips beside
the menu, headings, footer and row width move with them; focus ids come
from the style the app gives. SolidCraft builds the entries from its
commands and keeps drags, reveal and ribbon tooltips, persistent paint and
the sketch family menus.
Tested with no app: a row and a grid icon are hot under the pointer, run
when clicked, open their menu on a right-click, run nothing when disabled
and toggle their pin.

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

* Ribbon: customising drags and persistent paint are the component's, as returned actions (#12939)

The ribbon now starts and marks drags of its row buttons, gallery cells,
menu rows and grid icons, and returns a drop as Action::Drop (before a
button, or at a group's end); drag_end draws the ghost (the app gives the
icon) and returns a command dragged off the row. paint_deferred paints the
buttons the app paints after its commands ran, from the look the app
resolves (DeferredLook), and publishes what is pressed and focused.
SolidCraft keeps its store (toolbar_custom), runs Drop and the removal,
resolves each persistent button's look and its tooltip; its own drag
payload, ghost and paint code are gone.
Tested with no app: drops before a button and at a group's end, a button
dragged off the row removed with its ghost crossed out, and a deferred
button painted pressed and counted.

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

* App bar: the application bar is a component over plain bar data (#12939)

app_bar::show draws the app's mark and name, Home, the File button, the
file actions, Help, Settings, Report a bug and the version link inside the
title bar's content rect, from plain data (AppBar, BarButton, Report,
Version) with names from Handles, and returns what was chosen (Home, File,
Button, Help, Report, Version) with the places it leaves to the app: its
quick-access buttons, an offer pill, the document tabs, the assistant and
the search field. Its layout (report and version room, the search rect)
moves with it. SolidCraft gives its mark, name, version and tips, runs the
actions and draws its quick access, Update pill, tabs and assistant there.
Tested with no app: each control's click, a disabled button, the places
returned, and a compact bar.

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

* Ribbon: measure button labels in a theme colour, and keep toolbar.rs fully scanned (#12939)

The label measure moved from toolbar.rs to ribbon.rs with a literal white,
which the colour scan counts against ribbon.rs; the colour plays no part
in a width, so it is the theme's text colour. toolbar.rs has no colour
literal left, so its allowance goes. The scan reads a file up to its first
#[cfg(test)]: toolbar.rs's test-only helpers move down to its tests, so
none of its code is skipped.

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

* App bar: the document tab strip is a component over plain tab data (#12939)

app_bar::tabs draws the document tabs from plain data (TabStrip, DocTab,
TabMenuRow, a TabRename over the text being typed) and returns what was
chosen in the order it happened (Select, Close, Menu, Reorder,
StartRename, RenameDone/Cancelled, Maximize, New). Dragging to reorder,
the tab menu, the » list and the rename field stay in the component; its
drag, menu and list state are keyed by the id the app gives. SolidCraft
builds the tabs from its documents and runs the actions with its usual
precedence; its own strip drawing is gone. Its test-only helpers sit with
its tests, so the colour scan still reads all of documents.rs.
Tested with no app: a tab click, a close, the + button, a tab-menu row and
a drag that reorders.

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

* App bar: the File menu and the workspace switcher are components (#12939)

app_bar::file_menu draws the in-window File menu from plain rows
(FileRow: items, button rows with a tip, the Open Recent submenu,
separators) with ids from the app, and returns the row chosen and
whether the menu closes. app_bar::switcher draws the workspace switcher
(name, caption, icon, accent, folded and staged looks) and returns its
click; SolidCraft opens its own Workspace menu from it. SolidCraft builds
the File rows from its actions and runs the id chosen.
Tested with no app: a File row returns its id and closes the menu, a
disabled row returns nothing and keeps it open, a click elsewhere closes
it, and the switcher returns its click.

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

* App bar: the search field's width takes the palette shortcut, as main's command search does (#12939)

Main's CommandSearch::closed_width now takes the shortcut the closed field shows
(3af51db55, the command search can move off S). The ribbon's tab row already gets its width
from the host; the app bar's search_rect computed its own, so AppBar gets the shortcut as
data (search_shortcut) and SolidCraft passes keymap::display_for(app, "ui.palette").

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

* Ribbon: the panel lookup is panel_button, not a raw egui button to the UI-kit guard (#12939)

PanelButtons::button returned the host's data for a button the ribbon drew; the UI-kit
guard reads any `.button(` call as a raw egui widget and failed toolbar.rs on it.

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

* App bar: the tab strip says whether its list of every tab is open (#12939)

The tab strip keeps its list's open flag under the host's strip id, so main's icon audit,
which read the old sc_doc_more memory id, found the list closed. app_bar::tab_list_open
answers for a strip (the component reads it too) and documents::tab_list_open for
SolidCraft's; the audit asks that instead of a memory id. Its expectation is unchanged.

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

* Ribbon: the menu-tip test hosts its drop-down in the kit's Container (#12939)

cargo xtask ui-containers allows outer hosts only through ui_kit::Container; the test moved
out of toolbar.rs into ribbon_tests.rs and kept a bare egui::Area.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:50:19 +00:00
github-actions[bot] dfd9512e2e Regenerate generated files and fold pending files [skip ci] 2026-11-09 06:08:50 +00:00
bherbruck 8ffb97d541 convert: flat parity pins the bodies that differ, not the corpus size (#13999)
The corpus grows as cases are added, so the merged kernel -> main sync saw
1,337 bodies instead of 1,332 (all five new ones identical) and failed.
Every differing body is already pinned by name; the totals now check that
the rest are identical or within rounding.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:27:13 -05:00
Jannis Ruellmann 291d01f351 Insert Part: countersunk socket head screws (ISO 10642) join the standard parts (#13361) (#13362)
Adds the `countersunk_socket_screw` family, M3 to M20, from the ISO 10642
table (head diameter dk, head height k, socket size s and depth t). The head
is a 90 degree cone sunk below the seating plane, so the top of the head is
z = 0, level with the face, and the length counts the head. A length no longer
than the head is refused.

Seated at a hole, the foot of the head's cone goes on the rim picked (where
the hole's bore meets its countersink), moved along the axis by the bore's
radius over the shank's, so the cone lies on the countersink's and the top is
level with the face. The difference is signed: a clearance hole sits the cone
deeper, a tap drill higher. With no bore known there is no move. A countersink
of another angle is not detected.

M2 and M2.5 are left out: only one of the tables compared lists them. The key
engagement depth t is the newer edition's, which differs from the 2013 edition
at M4, M5, M8 and M10.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 05:23:08 +00:00
Dan Willis 34e6e4b342 Crash tests: build on Windows with warnings denied (#13193)
The clean_read_only case of crash_tests (#12891) declared `sessions` outside
its #[cfg(unix)] block, so on Windows it was unused and
`cargo clippy -p solidcraft-engine --all-targets -- -D warnings` failed to
build the engine's tests. Declare it inside the Unix block, where it is used.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:17:13 +00:00
bherbruck 17daee946c Keep solidcore/ self-contained: layers rule and an export-and-check CI job (#13995)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:51:54 +00:00
jyviko 11a799b565 Preferences window: a component over the host's pages; SolidCraft's Preferences is that window (#13987)
* Preferences window: the window shell as a component over host pages (#12942)

prefs_window draws the page list, the search over every page with its
outline of the current match, Restore Defaults for the page shown, and
OK / Cancel / Apply over a snapshot the host takes, compares and puts
back (Host). Pages are HostPage { icon, label, group, handle, scroll,
draw, defaults }. The kit's page list takes closures and a host Icon so
the window can draw through it. Tested from two fake pages, no app.

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

* Preferences: SolidCraft's Preferences is the window component with its pages (#12942)

prefs::show draws through prefs_window::show; SolidCraft's twelve pages
are HostPages with their bodies unchanged, and SolveApp is the Host: its
Snapshot, the panel-layout-first restore, shortcut capture and the
Shortcuts/Plugins search hooks. The ui.prefs buttons press the same
window buttons. The window's open, section and filter move to
prefs_window.shell (path change only in the other files); the old
search, page loop, land_on and close code is gone.

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

* Preferences window: the names it publishes and its search id are the host's (#12942)

Options takes a prefix for every registry name the window and its search
field publish (defaults, search, search:frame, search:clear, page_body,
window, footer, ok, apply, cancel, actions), and the search field's id
comes from the window's id, so two windows never share one. SolidCraft
passes "prefs", so its names stay as they were.

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

* Settings search: the current match and the revealed row are published under the window's prefix (#12942)

settings_search::begin takes the prefix of the window it searches, so
the current match is `{prefix}:search:current`; a Reveal carries the
prefix of the window that opened it, so the outlined row is
`{prefix}:revealed` wherever it draws. The Preferences window passes its
own prefix; SolidCraft's is "prefs", so its names are unchanged.

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

* Preferences: main's newer callers read open and filter through the window's shell (#12942)

The panel runtime, the toolbar's modal guard and four tests written on main after the
batch base still named the fields the window component took over.

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

* Preferences window: Apply runs the host's apply step; SolidCraft's switches the shared panel layout (#12942)

Main's Apply now enables or disables the shared panel layout before it takes the new
snapshot (eaf8f27a7). The window component gets Host::apply for that step, run before the
snapshot; press returns its error, so a command reports it, and the footer keeps it as the
window's restore error, as main did (Apply waits, a close reverts).
production_setting_cancel_preserves_classic_and_apply_enables_the_real_host fails with the
step left out; a_failed_apply_keeps_the_last_snapshot_and_the_footer_shows_it_until_a_close_reverts
pins both paths on the component.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-11-09 04:34:14 +00:00
bherbruck ee00ae925f Kernel PRs go to main; the kernel branch is for multi-step work only (#13986)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:26:51 +00:00
PeppaWutz f7ae792b69 Agents and scripts: New Design describes its exact parameters and reply (#13305) (#13322)
* Agents and scripts: New Design describes its exact parameters and reply (#13305)

file.new now publishes typed input and output schemas, as the linked-component,
canvas and Browser folder commands do. The parameter type reads `name` as the
handler does, and the reply is built from a typed struct that serializes to the
same JSON as before. The call is checked in warn and strict mode, so a
misspelt key is reported. Behaviour, messages and replies are unchanged with the check off; with it on,
a `name` that is not text is now reported in warn and refused in strict.
file.new is removed from the list of commands without schemas.

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

* Fix unconverted.txt after merge
2026-11-09 03:44:31 +00:00
bherbruck 5217d48a79 CODEOWNERS: solidcore/ owned by the solidcore-owners team, one reviewer per PR (#13973)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:31:34 +00:00
jyviko f96b5e067d Fonts: the kit's type API is ui_kit::type_face, the font data stays with the host (#13421)
* Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)

A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

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

* UI kit: the widget registry is a shared crate, ui-registry (#12936)

The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

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

* Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)

TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

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

* UI kit: Preferences search is a kit module, settings_search (#13256)

prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

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

* UI kit: the kit owns the menu entry and the list width (#13257)

Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

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

* UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)

parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

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

* UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)

The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

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

* UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)

LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

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

* UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)

continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

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

* UI kit: length fields take unit scaling and length parsing from the host (#13270)

The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

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

* Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)

geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

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

* Material editor: the Remove buttons are translated (material.editor.remove)

The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

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

* Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)

bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

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

* i18n tests: the painted-English sweeps install the catalogs themselves (#13290)

no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

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

* Theme: the icon palette leaves Tokens; the icon renderer owns it (#13304)

A theme file's icon palette was a field of Tokens, so the token set depended on the icon roles
ui-egui's build script generates. It is now the icon renderer's per-thread state
(icon_svg::set_theme_palette), set alongside the theme's palette by theme::set_file_palette, and
theme_files resolves it with resolve_icon_palette (the same overlay merge as the tokens) and
exports it as an argument. Rendered pixels, theme files and exports are unchanged.

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

* Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13304)

Tokens with SolidCraft's light and dark values in code (palettes.rs moves in from
assets/styles/ui, with the eight primary strokes it shared with the icon artwork written as
icon_strokes), colour vision, TabAccent, contrast checks, apply/visuals and the per-thread and
per-context theme state move into crates/theme, class Shared. It holds no data files and does not
depend on theme files or icon roles; with nothing set, components draw with the built-in tokens.

The theme system stays in ui-egui's theme module: the custom-theme glue (prepared theme, tab
targets and their fade, blur intent), publish_tab (was TabAccent::publish, which now stores its
result through TabAccent::set_current and set_published), set_file_palette, ViewportTheme.
ui-egui's build script no longer generates the primary-stroke constants; an icon test holds the
crate's literals equal to palette.toml's. Call sites use solidcraft_theme paths with no
re-export (one rename pass). Tokens, theme files and rendered pixels are unchanged.

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

* Theme: the icon lint and the docs read palettes.rs at crates/theme (#13304)

lint_icons.py compared the icon review page with assets/styles/ui/palettes.rs, which moved into
solidcraft-theme; it reads crates/theme/src/palettes.rs now (cargo xtask icons passes again), and
the READMEs, docs and test comments name the new path. set_custom and scoped_tokens say they set
tokens only, so a host with an icon renderer sets its palette too; the field contrast test applies
each built-in theme with set_file_palette, as the app does.

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

* Theme: format the field contrast test (#13304)

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

* MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947)

JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool
definitions with their annotations, the argument check and the text and
image results move to crates/mcp-server, which has no engine or GPU
dependency and is classed Shared. SolidCraft's tools, instructions,
resources and the agent's folders stay in crates/mcp and register into it
through the Tools trait; its public API, tools list and replies are unchanged.

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

* MCP: the shared server tests what it owns, on its own (#12947)

Batches (mixed, notifications only, the 1000 limit), an unasked response,
the protocol version fallback, null arguments, enum mismatches, the empty
default lists and the list-then-call order Tools::call relies on are tested
in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out
of SolidCraft's wrapper tests.

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

* Docs: the architecture table lists the shared crates and the MCP split (#12947)

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

* Control: the token files and Effect are a shared crate, solidcraft-control (#12946)

The per-launch token, its files, the discovery file and the client's token
lookup move from the engine to crates/control, with no engine dependency.
Which app they belong to (name, data folder, environment, UTC format,
default port) is an `App` value; SolidCraft's is engine::control_auth::APP.
`Effect` moves with them; which requests do what stays in the engine's
control_pending. Callers switch paths; files and messages are unchanged.

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

* Control: the loopback control server is shared; SolidCraft gives it its Protocol (#12946)

The token-admitted JSON-lines server (framing and line limit, connection
cap, control.running/pending/capabilities, busy and timeout replies,
control.subscribe events), ControlRequest and the design watch move to
solidcraft-control as `server::Hub`. What a request means (its name in
messages, its effect, command id, step count, the batch wait, the
capabilities) is the app's `server::Protocol`; SolidCraft's is in
apps/solidcraft/src/control_server.rs with its listeners and token files.
The wire format is unchanged and the server's tests move with it.

Also from review: Effect::when_done and the timeout text take the app's
word for its document ("design" for SolidCraft), the Listen docs name no
SolidCraft preference, and bench_assistant.rs is formatted.

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

* Control: the shared server speaks the app's words and listens on loopback only (#12946)

The change event, the revision method, the docs path in the unauthorized
reply and the document noun come from the app's Protocol; SolidCraft's
replies are byte-identical. The design watch is the DocumentWatch.
Hub::listen refuses a listener off loopback, so no app can put the
channel on the network; SolidCraft binds 127.0.0.1 as before.
control_pending.rs is formatted.

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

* Control: ui.screenshot and ui.resize are shared over egui (#12946)

The window's screenshots in flight (the frame waits, the AO-settled
shot, the hidden-window and chat-turn refusals, the 8 s deadline) move
from SolveApp to solidcraft_control::screenshot::Shots, with the PNG
encoding; ui.resize and the hidden-window test go to window. SolidCraft
passes its AO settle, its issue report's frames and its vfs write, so a
second app can serve ui.screenshot with the shared crate alone. The issue
report and tests reach the queue as app.shots; replies are unchanged.

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

* Control: ui.screenshot and ui.resize are their own shared crate, solidcraft-control-egui (#12946)

d8d35d454 made egui and image dependencies of solidcraft-control, so the
headless engine, the CLI and the MCP bridge compiled egui. The screenshot
and window modules move unchanged to solidcraft-control-egui; ui-egui
uses them from there. cargo tree finds no egui under engine, mcp or control.

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

* Control: the app gives the screenshot reply for no frame (#12946)

The shared text named SolidCraft's ui.render. Shots::new takes the app's
words; SolidCraft passes its reply unchanged.

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

* Control: tests for the screenshot gate, a hidden window and frames nobody waits for (#12946)

Shots::issue takes a frame on the third frame and 150 ms after the request,
or 500 ms later for a view that keeps moving; a hidden window refuses a
request and answers one waiting; with nothing pending the frames that came
are dropped. Each fails on a one-line mutation of screenshot.rs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* UI kit: the type API moves to ui_kit::type_face (#13327)

The weights (medium, semibold, bold, RichTextExt), the family and fallback
names, the script checks and the CJK font loader state move out of fonts.rs
into ui_kit::type_face, which holds no font data and no engine reference.
fonts.rs keeps the host part: the embedded Plex faces, ui_font over the
engine's font lookup, the system font search and the install functions.
with_weight_families lets a host that installs no faces draw the kit's
weights with egui's default faces. Call sites use the new path; no
re-export at the old one. The font-family guard names the new file.

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

* Fonts: which faces the web shell fetches is the host's choice (#13327)

web_script_font_kind and needs_chinese_font decide which on-demand faces
SolidCraft's web shell downloads, so they move from the kit's type API back
to fonts.rs, with their assertions in a host test. The Arabic fallback's doc
link points at solidcraft_ui_text::bidi.

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

* UI kit: the CJK font loader tests install the catalogs they read (#13327)

CjkFontLoader resolves the language through the installed catalogs, so its
tests passed only when another test in the process had installed them first
(they failed under `-- cjk` alone). Each now installs them itself.

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

* UI kit: a host with no faces of its own calls type_face::install (#13327)

The kit's weights draw only once the context binds the weight families. The
host contract is now stated on the module and in docs/ui-kit.md: a host with
faces binds them in its definitions (with_weight_families binds any it leaves
out), a host with none calls type_face::install once. A test draws the
semibold name of a kit status header on a bare context after only that call;
without it egui has no face for the family and draws no glyphs. Also puts
`pub mod type_face` in rustfmt's order.

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

* Fonts: the Thai, Hebrew and Arabic names are imported only off the web (#13327)

Only the desktop's system font search uses them, so the wasm build warned
that the imports were unused.

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

* Fonts: the wasm test build imports the Thai, Hebrew and Arabic names (#13327)

The font tests use them on every target, so the import is gated on
test or a desktop target, not on the desktop alone.

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

* UI kit: type_face::install is install_weight_families (#13327)

The kit's entry point had the same name as the host's private
fonts::install, which installs SolidCraft's faces. The new name says what
the kit call does.

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

* UI kit: install_weight_families says it replaces the font definitions (#13327)

It sets egui's default definitions. It cannot merge into the context's own:
egui keeps the definitions set this frame private until the next one, and
reading the current fonts before the first frame panics. The doc and
docs/ui-kit.md say so; a host with faces passes its definitions through
with_weight_families instead.

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

* UI kit: the bare-context test also checks a context without the binding (#13327)

The control half: a bare context that never called install_weight_families
has no semibold family and no face that draws the name, which is why the
call is the host contract.

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

* UI kit: the egui-faces test sets up through install_weight_families (#13327)

It inlined the entry point's body; it now calls the call a host makes.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:56:55 +00:00
jyviko cc55f59bf2 History bar: a component over plain step data that returns what the user chose (#13429)
* Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)

A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

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

* UI kit: the widget registry is a shared crate, ui-registry (#12936)

The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

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

* Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)

TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

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

* UI kit: Preferences search is a kit module, settings_search (#13256)

prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

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

* UI kit: the kit owns the menu entry and the list width (#13257)

Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

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

* UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)

parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

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

* UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)

The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

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

* UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)

LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

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

* UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)

continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

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

* UI kit: length fields take unit scaling and length parsing from the host (#13270)

The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

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

* Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)

geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

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

* Material editor: the Remove buttons are translated (material.editor.remove)

The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

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

* Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)

bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

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

* i18n tests: the painted-English sweeps install the catalogs themselves (#13290)

no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

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

* Theme: the icon palette leaves Tokens; the icon renderer owns it (#13304)

A theme file's icon palette was a field of Tokens, so the token set depended on the icon roles
ui-egui's build script generates. It is now the icon renderer's per-thread state
(icon_svg::set_theme_palette), set alongside the theme's palette by theme::set_file_palette, and
theme_files resolves it with resolve_icon_palette (the same overlay merge as the tokens) and
exports it as an argument. Rendered pixels, theme files and exports are unchanged.

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

* Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13304)

Tokens with SolidCraft's light and dark values in code (palettes.rs moves in from
assets/styles/ui, with the eight primary strokes it shared with the icon artwork written as
icon_strokes), colour vision, TabAccent, contrast checks, apply/visuals and the per-thread and
per-context theme state move into crates/theme, class Shared. It holds no data files and does not
depend on theme files or icon roles; with nothing set, components draw with the built-in tokens.

The theme system stays in ui-egui's theme module: the custom-theme glue (prepared theme, tab
targets and their fade, blur intent), publish_tab (was TabAccent::publish, which now stores its
result through TabAccent::set_current and set_published), set_file_palette, ViewportTheme.
ui-egui's build script no longer generates the primary-stroke constants; an icon test holds the
crate's literals equal to palette.toml's. Call sites use solidcraft_theme paths with no
re-export (one rename pass). Tokens, theme files and rendered pixels are unchanged.

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

* Theme: the icon lint and the docs read palettes.rs at crates/theme (#13304)

lint_icons.py compared the icon review page with assets/styles/ui/palettes.rs, which moved into
solidcraft-theme; it reads crates/theme/src/palettes.rs now (cargo xtask icons passes again), and
the READMEs, docs and test comments name the new path. set_custom and scoped_tokens say they set
tokens only, so a host with an icon renderer sets its palette too; the field contrast test applies
each built-in theme with set_file_palette, as the app does.

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

* Theme: format the field contrast test (#13304)

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

* MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947)

JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool
definitions with their annotations, the argument check and the text and
image results move to crates/mcp-server, which has no engine or GPU
dependency and is classed Shared. SolidCraft's tools, instructions,
resources and the agent's folders stay in crates/mcp and register into it
through the Tools trait; its public API, tools list and replies are unchanged.

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

* MCP: the shared server tests what it owns, on its own (#12947)

Batches (mixed, notifications only, the 1000 limit), an unasked response,
the protocol version fallback, null arguments, enum mismatches, the empty
default lists and the list-then-call order Tools::call relies on are tested
in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out
of SolidCraft's wrapper tests.

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

* Docs: the architecture table lists the shared crates and the MCP split (#12947)

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

* Control: the token files and Effect are a shared crate, solidcraft-control (#12946)

The per-launch token, its files, the discovery file and the client's token
lookup move from the engine to crates/control, with no engine dependency.
Which app they belong to (name, data folder, environment, UTC format,
default port) is an `App` value; SolidCraft's is engine::control_auth::APP.
`Effect` moves with them; which requests do what stays in the engine's
control_pending. Callers switch paths; files and messages are unchanged.

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

* Control: the loopback control server is shared; SolidCraft gives it its Protocol (#12946)

The token-admitted JSON-lines server (framing and line limit, connection
cap, control.running/pending/capabilities, busy and timeout replies,
control.subscribe events), ControlRequest and the design watch move to
solidcraft-control as `server::Hub`. What a request means (its name in
messages, its effect, command id, step count, the batch wait, the
capabilities) is the app's `server::Protocol`; SolidCraft's is in
apps/solidcraft/src/control_server.rs with its listeners and token files.
The wire format is unchanged and the server's tests move with it.

Also from review: Effect::when_done and the timeout text take the app's
word for its document ("design" for SolidCraft), the Listen docs name no
SolidCraft preference, and bench_assistant.rs is formatted.

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

* Control: the shared server speaks the app's words and listens on loopback only (#12946)

The change event, the revision method, the docs path in the unauthorized
reply and the document noun come from the app's Protocol; SolidCraft's
replies are byte-identical. The design watch is the DocumentWatch.
Hub::listen refuses a listener off loopback, so no app can put the
channel on the network; SolidCraft binds 127.0.0.1 as before.
control_pending.rs is formatted.

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

* Control: ui.screenshot and ui.resize are shared over egui (#12946)

The window's screenshots in flight (the frame waits, the AO-settled
shot, the hidden-window and chat-turn refusals, the 8 s deadline) move
from SolveApp to solidcraft_control::screenshot::Shots, with the PNG
encoding; ui.resize and the hidden-window test go to window. SolidCraft
passes its AO settle, its issue report's frames and its vfs write, so a
second app can serve ui.screenshot with the shared crate alone. The issue
report and tests reach the queue as app.shots; replies are unchanged.

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

* Control: ui.screenshot and ui.resize are their own shared crate, solidcraft-control-egui (#12946)

d8d35d454 made egui and image dependencies of solidcraft-control, so the
headless engine, the CLI and the MCP bridge compiled egui. The screenshot
and window modules move unchanged to solidcraft-control-egui; ui-egui
uses them from there. cargo tree finds no egui under engine, mcp or control.

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

* Control: the app gives the screenshot reply for no frame (#12946)

The shared text named SolidCraft's ui.render. Shots::new takes the app's
words; SolidCraft passes its reply unchanged.

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

* Control: tests for the screenshot gate, a hidden window and frames nobody waits for (#12946)

Shots::issue takes a frame on the third frame and 150 ms after the request,
or 500 ms later for a view that keeps moving; a hidden window refuses a
request and answers one waiting; with nothing pending the frames that came
are dropped. Each fails on a one-line mutation of screenshot.rs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* UI kit: the type API moves to ui_kit::type_face (#13327)

The weights (medium, semibold, bold, RichTextExt), the family and fallback
names, the script checks and the CJK font loader state move out of fonts.rs
into ui_kit::type_face, which holds no font data and no engine reference.
fonts.rs keeps the host part: the embedded Plex faces, ui_font over the
engine's font lookup, the system font search and the install functions.
with_weight_families lets a host that installs no faces draw the kit's
weights with egui's default faces. Call sites use the new path; no
re-export at the old one. The font-family guard names the new file.

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

* Fonts: which faces the web shell fetches is the host's choice (#13327)

web_script_font_kind and needs_chinese_font decide which on-demand faces
SolidCraft's web shell downloads, so they move from the kit's type API back
to fonts.rs, with their assertions in a host test. The Arabic fallback's doc
link points at solidcraft_ui_text::bidi.

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

* UI kit: the CJK font loader tests install the catalogs they read (#13327)

CjkFontLoader resolves the language through the installed catalogs, so its
tests passed only when another test in the process had installed them first
(they failed under `-- cjk` alone). Each now installs them itself.

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

* UI kit: a host with no faces of its own calls type_face::install (#13327)

The kit's weights draw only once the context binds the weight families. The
host contract is now stated on the module and in docs/ui-kit.md: a host with
faces binds them in its definitions (with_weight_families binds any it leaves
out), a host with none calls type_face::install once. A test draws the
semibold name of a kit status header on a bare context after only that call;
without it egui has no face for the family and draws no glyphs. Also puts
`pub mod type_face` in rustfmt's order.

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

* Fonts: the Thai, Hebrew and Arabic names are imported only off the web (#13327)

Only the desktop's system font search uses them, so the wasm build warned
that the imports were unused.

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

* Fonts: the wasm test build imports the Thai, Hebrew and Arabic names (#13327)

The font tests use them on every target, so the import is gated on
test or a desktop target, not on the desktop alone.

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

* UI kit: type_face::install is install_weight_families (#13327)

The kit's entry point had the same name as the host's private
fonts::install, which installs SolidCraft's faces. The new name says what
the kit call does.

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

* UI kit: install_weight_families says it replaces the font definitions (#13327)

It sets egui's default definitions. It cannot merge into the context's own:
egui keeps the definitions set this frame private until the next one, and
reading the current fonts before the first frame panics. The doc and
docs/ui-kit.md say so; a host with faces passes its definitions through
with_weight_families instead.

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

* UI kit: the bare-context test also checks a context without the binding (#13327)

The control half: a bare context that never called install_weight_families
has no semibold family and no face that draws the name, which is why the
call is the host contract.

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

* UI kit: the egui-faces test sets up through install_weight_families (#13327)

It inlined the entry point's body; it now calls the call a host makes.

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

* History bar: the bottom History bar as a component over plain step data (#12945)

history_bar draws the grip and title, the History controls and a strip of step
chips with the marker, from plain data (Bar, Step, StepState), and returns
Actions: Seek (a control or the dropped marker, as a slot and its side of the
marker), Play, ControlsMenu, Select with its modifiers, Edit, Menu (the app shows
its own items) and Reorder. The app draws its own chips, trays and inline rows
through the strip (Contents). It keeps only the drag and reads nothing of the app.
Tested with no app: clicks, a double click, right clicks, step and marker drags on
both sides of the marker, a folded chip of the app's, the controls with and
without Play, a narrow bar, an empty bar and a release outside the window.

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

* History bar: SolidCraft's bottom History bar is the component with its features (#12945)

timeline_contents describes the features as Steps and draws its folded runs,
folders, joint rows, trays and the trailing joints through the component's
strip; it runs the returned actions with the same calls as before (select or
pick into the dialog, Edit Feature, the one context menu, roll_to at the
dropped slot's design position, drop_feature). The History controls' actions
run right after the header, as before; a chip's click and menu now run once
the strip is drawn. transport_response (the left History) routes through the
same transport actions. The bar's own drag and slot code leaves timeline.rs.

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

* History bar: the grip and the History controls publish under the bar's Handles (#12945)

The grip's id comes from the bar's scope and its grip handle instead of one
global id, so two bars never share a drag. The History controls are published
as Handles::controls followed by their id through HistoryTransport::show_as;
show keeps "history:" for its other callers. SolidCraft passes "history:", so
its names are unchanged.

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

* History bar: a step carries its icon's accent; the bar reads no kind names (#12945)

Step.kind (matched against SolidCraft's feature type names for the icon's
accent) becomes Step.icon_accent, an icon palette name or none, painted
through HistoryChip::paint_with_accent. SolidCraft passes what its kinds give
(feature_icon_accent of the type name and icon), so its chips are unchanged;
another app gives its own accents.

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

* History bar: tests for a drop at the marker from after it and for the chip's marks (#12945)

A step from after the marker dropped at the marker's own slot returns the side
it lands on; highlighted, rolled back and failed steps reach their chips (lit,
faded, the error fill); only the floating card dots the last step before the
marker, and only that step.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:48:47 +00:00
PeppaWutz 38598a7860 Agents and scripts: Installed Slicers describes its exact parameters and reply (#13306) (#13340)
* Agents and scripts: Installed Slicers describes its exact parameters and reply (#13306)

file.slicers now publishes typed input and output schemas. It takes no
parameters, so a call with a key is reported in warn and refused in strict,
and a call without one runs as before. The reply is built from a typed
value and serializes to the same JSON as before: each slicer has id, name,
program and icon, the last two null when missing. Its line leaves
unconverted.txt.

Two test helpers in tests/mesh_convert.rs become crate-visible so the new
reply test takes turns with the send tests that fake the slicer list.

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

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

* Format the imports after merging main

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 02:33:43 +00:00
jyviko 92af38e470 Start page: a component that takes the host's entries and icons and returns the entry chosen (#13410)
* Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)

A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

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

* UI kit: the widget registry is a shared crate, ui-registry (#12936)

The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

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

* Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)

TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

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

* UI kit: Preferences search is a kit module, settings_search (#13256)

prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

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

* UI kit: the kit owns the menu entry and the list width (#13257)

Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

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

* UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)

parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

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

* UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)

The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

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

* UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)

LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

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

* UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)

continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

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

* UI kit: length fields take unit scaling and length parsing from the host (#13270)

The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

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

* Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)

geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

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

* Material editor: the Remove buttons are translated (material.editor.remove)

The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

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

* Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)

bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

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

* i18n tests: the painted-English sweeps install the catalogs themselves (#13290)

no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

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

* Theme: the icon palette leaves Tokens; the icon renderer owns it (#13304)

A theme file's icon palette was a field of Tokens, so the token set depended on the icon roles
ui-egui's build script generates. It is now the icon renderer's per-thread state
(icon_svg::set_theme_palette), set alongside the theme's palette by theme::set_file_palette, and
theme_files resolves it with resolve_icon_palette (the same overlay merge as the tokens) and
exports it as an argument. Rendered pixels, theme files and exports are unchanged.

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

* Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13304)

Tokens with SolidCraft's light and dark values in code (palettes.rs moves in from
assets/styles/ui, with the eight primary strokes it shared with the icon artwork written as
icon_strokes), colour vision, TabAccent, contrast checks, apply/visuals and the per-thread and
per-context theme state move into crates/theme, class Shared. It holds no data files and does not
depend on theme files or icon roles; with nothing set, components draw with the built-in tokens.

The theme system stays in ui-egui's theme module: the custom-theme glue (prepared theme, tab
targets and their fade, blur intent), publish_tab (was TabAccent::publish, which now stores its
result through TabAccent::set_current and set_published), set_file_palette, ViewportTheme.
ui-egui's build script no longer generates the primary-stroke constants; an icon test holds the
crate's literals equal to palette.toml's. Call sites use solidcraft_theme paths with no
re-export (one rename pass). Tokens, theme files and rendered pixels are unchanged.

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

* Theme: the icon lint and the docs read palettes.rs at crates/theme (#13304)

lint_icons.py compared the icon review page with assets/styles/ui/palettes.rs, which moved into
solidcraft-theme; it reads crates/theme/src/palettes.rs now (cargo xtask icons passes again), and
the READMEs, docs and test comments name the new path. set_custom and scoped_tokens say they set
tokens only, so a host with an icon renderer sets its palette too; the field contrast test applies
each built-in theme with set_file_palette, as the app does.

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

* Theme: format the field contrast test (#13304)

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

* MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947)

JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool
definitions with their annotations, the argument check and the text and
image results move to crates/mcp-server, which has no engine or GPU
dependency and is classed Shared. SolidCraft's tools, instructions,
resources and the agent's folders stay in crates/mcp and register into it
through the Tools trait; its public API, tools list and replies are unchanged.

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

* MCP: the shared server tests what it owns, on its own (#12947)

Batches (mixed, notifications only, the 1000 limit), an unasked response,
the protocol version fallback, null arguments, enum mismatches, the empty
default lists and the list-then-call order Tools::call relies on are tested
in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out
of SolidCraft's wrapper tests.

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

* Docs: the architecture table lists the shared crates and the MCP split (#12947)

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

* Control: the token files and Effect are a shared crate, solidcraft-control (#12946)

The per-launch token, its files, the discovery file and the client's token
lookup move from the engine to crates/control, with no engine dependency.
Which app they belong to (name, data folder, environment, UTC format,
default port) is an `App` value; SolidCraft's is engine::control_auth::APP.
`Effect` moves with them; which requests do what stays in the engine's
control_pending. Callers switch paths; files and messages are unchanged.

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

* Control: the loopback control server is shared; SolidCraft gives it its Protocol (#12946)

The token-admitted JSON-lines server (framing and line limit, connection
cap, control.running/pending/capabilities, busy and timeout replies,
control.subscribe events), ControlRequest and the design watch move to
solidcraft-control as `server::Hub`. What a request means (its name in
messages, its effect, command id, step count, the batch wait, the
capabilities) is the app's `server::Protocol`; SolidCraft's is in
apps/solidcraft/src/control_server.rs with its listeners and token files.
The wire format is unchanged and the server's tests move with it.

Also from review: Effect::when_done and the timeout text take the app's
word for its document ("design" for SolidCraft), the Listen docs name no
SolidCraft preference, and bench_assistant.rs is formatted.

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

* Control: the shared server speaks the app's words and listens on loopback only (#12946)

The change event, the revision method, the docs path in the unauthorized
reply and the document noun come from the app's Protocol; SolidCraft's
replies are byte-identical. The design watch is the DocumentWatch.
Hub::listen refuses a listener off loopback, so no app can put the
channel on the network; SolidCraft binds 127.0.0.1 as before.
control_pending.rs is formatted.

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

* Control: ui.screenshot and ui.resize are shared over egui (#12946)

The window's screenshots in flight (the frame waits, the AO-settled
shot, the hidden-window and chat-turn refusals, the 8 s deadline) move
from SolveApp to solidcraft_control::screenshot::Shots, with the PNG
encoding; ui.resize and the hidden-window test go to window. SolidCraft
passes its AO settle, its issue report's frames and its vfs write, so a
second app can serve ui.screenshot with the shared crate alone. The issue
report and tests reach the queue as app.shots; replies are unchanged.

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

* Control: ui.screenshot and ui.resize are their own shared crate, solidcraft-control-egui (#12946)

d8d35d454 made egui and image dependencies of solidcraft-control, so the
headless engine, the CLI and the MCP bridge compiled egui. The screenshot
and window modules move unchanged to solidcraft-control-egui; ui-egui
uses them from there. cargo tree finds no egui under engine, mcp or control.

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

* Control: the app gives the screenshot reply for no frame (#12946)

The shared text named SolidCraft's ui.render. Shots::new takes the app's
words; SolidCraft passes its reply unchanged.

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

* Control: tests for the screenshot gate, a hidden window and frames nobody waits for (#12946)

Shots::issue takes a frame on the third frame and 150 ms after the request,
or 500 ms later for a view that keeps moving; a hidden window refuses a
request and answers one waiting; with nothing pending the frames that came
are dropped. Each fails on a one-line mutation of screenshot.rs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 01:53:04 +00:00
PeppaWutz 0c4f55b60f Agents and scripts: Insert Mesh describes its exact parameters and reply (#13309) (#13341)
* Agents and scripts: Insert Mesh describes its exact parameters and reply (#13309)

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

Part of #2829.

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

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

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

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

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

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

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

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

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

---------

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

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

* Docs: sharper definition of a changed default

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

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

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

---------

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

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

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

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

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

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

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

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

* Fix unconverted.txt after merge

* Format the imports after merging main

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 00:50:54 +00:00
bherbruck 8f13e948a9 query + ops: whether two bodies meet, on a certified bracket of their boundary distance (#3144) (#13554)
query::contact decides whether two bodies meet from a certified bracket [lower, upper] of the distance between their boundaries; solidcore-ops exposes it as Body::meets, meets_each and boundary_distance. Undecided carries the bracket and is never read as either answer.

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

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

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

Fixes #13332

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

---------

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

Fixes #13505

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:32:42 +00:00
github-actions[bot] 1c8fdde4ba Regenerate generated files and fold pending files [skip ci] 2026-11-09 00:54:57 +00:00
bherbruck 59a85a1646 blend: a circle-class round is certified on its own torus (#3066) (#13900)
Moved from tangency-labs/solidcore#3102.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:26:25 -05:00
PeppaWutz 3ce8b875b1 Agents and scripts: Save As Mesh describes its exact parameters and reply (#13308) (#13353)
* Agents and scripts: Save As Mesh describes its exact parameters and reply (#13308)

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

Part of #2829.

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

* Fix unconverted.txt after merge
2026-11-09 00:23:39 +00:00
bherbruck 4890cebd36 fix(boolean): a proto edge's 3D curve and UV curves are trimmed to one interval (#13555)
Fixes #3214.

create_proto_edge trimmed a copy of the 3D curve to the curve interval, then
passed the same interval to each UV curve's trim. A trim snaps an end within
100 scaled zeros onto a knot of its own curve and writes it back, so a UV
curve's knot a few ulps from the 3D trim parameter moved the edge's interval
after the 3D curve had been cut: the new edge ran up to 2.4e-12 past its
curve's natural interval, and the result failed SmEdge test 2 (Bad Domain).

Reported in tangency-labs/solidcraft#13462: a rod cut by two counter-wound
screw sweeps of one channel profile. Each sweep's rail on the rod's cylinder
is a helix, the rails cross, and the crossing is solved from more than one
face pair a few 1e-12 apart.

The 3D curve and the UV curves whose trims accept the interval are now trimmed
to the one interval none of their trims snaps (exact_pieces'
common_trim_interval and trim_unsnapped, as the offset does since #3013). A UV
curve whose trim refuses the interval is trimmed alone, as before. Where no
trim snaps, the interval and the curves are unchanged bit for bit.

Tests: the corpus case booleans/13462-rod-cut-by-two-counter-wound-channel-coils
(closed-form volume, derivation in its expected.method) and
solidcore-ops boolean::a_rod_cut_by_two_counter_wound_channel_sweeps_is_valid,
which fails without the change (NotSolid) and passes with it, cut in turn and
by the sweeps' union. Goldens: topology.proto_topology_p1-p3,
boolean.merge_p1-p5 and boolean.topology_intersector_p1-p3 unchanged;
boolean.api_brep's one change (issue1519_bench_tubes_union_measured, 1e-18)
is the same on main without the change.

The C++ defect is recorded as WR-767 (plan/upstream-issues/).

Co-authored-by: Seth LaForge <sethml@ofb.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:16:44 -05:00
bherbruck bf78850423 Tests: an open setback corner now leaves a 9-edge hole (#13540) (#13895)
* claim: #13540 and #13542, the setback corner's tests after solidcore #2988

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

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

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

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

Fixes tangency-labs/solidcraft#13540

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

---------

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

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

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

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

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 00:14:25 +00:00
bherbruck 27d5a264f3 mass integrator: rate holds a range cut into pieces to the accuracy of the whole; Length's Deviations entry (#2894) (#13556)
Finishes the review asks of #2902 that main does not have yet:

- a_rate_holds_a_range_cut_into_pieces_to_the_accuracy_of_the_whole: OdeOptions::rate as
  Curve::length uses it, a range cut into pieces (1, 8, 64) each integrated to half the
  accuracy with the whole range's mean as its rate. On e^t over [0, 4], Runge's
  1 / (1 + 25 t^2) over [-1, 1] and 2 + sin 20t over [0, pi], the pieces' sum is within the
  accuracy of the closed form at 1e-4 to 1e-10. The existing rate test covers one range.
- The doc comment of the_final_step_ends_at_the_interval_end_exactly had been left above
  a_rate_that_is_not_a_finite_nonnegative_number_is_refused; it is back on its test, and the
  refusal test has its own.
- curve/curve/p5.rs: the Length entry of the header's Deviations says, as "Not upstream",
  that the accuracy is held over the whole curve instead of dDesiredAccuracy / span count
  per span (the integrator's header already names rate).

Tests only and doc comments: no result changes.

Co-authored-by: Ahmad Khan <khanahmad4527@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:07:12 -05:00
bherbruck 3b82c40ff7 Preserve pcurve parameters over partial edge support (#3203) (#13557)
Co-authored-by: Benjamin Davis <bendavis@gmail.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-11-08 18:57:37 -05:00
Loohi 956fe0070b Revolve: input and output schemas (part of #2829) (#13319)
* claim: part of #2829, revolve schema

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:55:09 +00:00
bherbruck a05b47339f fix: ContainsPeriodicValue moves a value just past the start down a period (#1518; carries #1572) (#13558)
* fix: ContainsPeriodicValue moves a value just past the start down a period (opt-in, #1518)

SmExtent1d::ContainsPeriodicValue sets a value within its tolerance of the
top of the period above an interval's start to the start, so a point of an
arc up to 1e-4 degrees past its start inverts to the start, and every point
of an ellipse arc shorter than that does (SmEllipse test 2).

New opt-in Kernel::fixes.periodic_value_past_start_moves_down (WR-509):
the value is moved down by the period and kept when that lies in the
interval, as the fork's tangency-labs/smlib#46. Extent1d gains *_with
variants taking &UpstreamFixes; the callers that hold a kernel pass it.
Off by default: upstream behaviour unchanged.

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

* extent1d: make the WR-509 fix the default, drop the opt-in flag

Since #2647 solidcore adds no UpstreamFixes flags. ContainsPeriodicValue now
always moves a value near the top of the period above the start down by the
period, kept where it lies in the interval, instead of setting it to the
start. The flag, the *_with variants and the old branch are gone; every
caller, including the kernel-less ones, gets the fix.

The flag-off halves of the #1518 tests are now the regression tests (they
fail on the old path). Upstream's MyTest for Extent2d::contains_periodic_point
moves from 1.0 to 0.9 (8.9 mod 4 inside [1, 3] +- 0.2); recorded in WR-509.

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

* goldens: the ten cases WR-509's ContainsPeriodicValue fix moves, recorded (#1518)

Measured against main 9dc37ee02 on one machine: 279 of 283 units are
unchanged; geom.extent2d (1 case), geom.extent1d (5), curve.ellipse_p2 (3)
and query.face_p3 (1) move. Each case is re-recorded on its own with
--bless --filter, and listed with its old and new values in
plan/findings/geom.extent1d--wr509-moved-ten-cases-2026-11-08.md. The
WR-509 record points there instead of the retired difftest run.

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

* test: the joined tube ends now check clean; #1518's ellipse line is gone (#1518)

wr579_near_coincident_tube_ends_join pinned the one check failure the joined body
had, SmEllipse test 2 on the 8.6e-5 ellipse piece where the top tube's wall meets
the seat's end plane. That is #1518's own case, and WR-509's fix removes it: the
check is now clean, so the test asks for that instead of the old line. On main the
same assertion fails with that line, so it is the fix's regression test on a real
body as well.

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

* extent1d: a value kept a period down reports its distance to the start, not a signed offset (#1518 review)

The value WR-509 keeps a period down can lie up to the tolerance below the
interval's start; dist_to_min now takes it as abs there (assert_valid reads the
two distances with min2), only for that case: other inputs keep the signed
value upstream gives them (making it abs for every input moves 46 extent1d and
30 assert_valid goldens). The regression test asserts 5e-5 for 360 - 5e-5 on
[0, 90]; three extent1d golden lines flip sign and are re-recorded one by one.

The findings record now says the ellipse cases' numbers are block sums, not
points, and that they and the face_p3 sector are not checked independently.

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

---------

Co-authored-by: Ivica Safranko <safranko@msn.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
2026-11-08 18:48:21 -05:00
bherbruck b53fa7a569 Blend gallery: case-specific notes and renders redrawn from this repo (#13561)
* kernel cases: drop two gallery notes about SolidCraft and the known-failing list

Case 2893's note described the application's error wording, and the
partial-chain case's note restated its known_failing.json line. Neither is
this repo's to say in a gallery note.

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

* kernel cases: KERNEL_CASES_SMB writes each case's bodies for its render

With the variable set to a directory, the runner writes a case's final
bodies, or the inputs of the refusal that ends it, as <case>.smb, so a
gallery render can be drawn with solidcore-diag from this repo alone.

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

* kernel cases: the refreshed fillet renders drawn with solidcore-diag

Thirteen of the fourteen refreshed renders are redrawn from the case's own
replay (KERNEL_CASES_SMB) with solidcore-diag --view -30,-30 --size 900x640,
so they carry no status caption or revision hash and can be regenerated
from this repo. Their gallery notes no longer describe the old views and
insets; each says what its case shows, from the case's definition.

cov-fillet-section-curvature-pipe-on-plate-r2 keeps its picture with the
caption strip cut off: the kernel's reader refuses the .smb the replay
writes for it (p2::Surface::read_from_db, status 1001), ASCII or binary.

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

---------

Co-authored-by: Markus Reichart <52602624+MarkusReichart@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:48:16 -05:00
bherbruck 4579e1ad50 Every package builds on the Rust release it declares (solidcore 1.85, ops and convert 1.88), and CI checks it (#3112) (#13563)
* ci: build each package on the Rust release its rust-version declares (#3112)

solidcore declares rust-version 1.85, but nothing checks it: rust-toolchain.toml pins
1.98 for every job. Since fc2212694 the crate no longer builds on 1.91, and it has not
built on 1.85 for longer. This job installs each declared version and runs
`cargo +<version> check` on the packages that declare it. It fails on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* solidcore builds on Rust 1.85 again, the release it declares; ops and convert declare 1.88 (#3112)

- blend_corner/mixed.rs: pass the side's curves to `nth_mut` as a slice
  (`as_mut_slice()`). Rust before 1.98 tried to unsize the argument of `of2m`
  instead of its result and refused the call (E0308, E0277): 1.91 to 1.97 could not
  build solidcore.
- fillet_corner/p1.rs, heal/face_props.rs, surface/volume/p2.rs: an error is passed
  on when its reason is a given one through a `matches!` guard on `&e`, not an
  `e @ (... { reason, .. })` binding. Binding a field after a by-move `@` builds
  only from 1.91 (E0382 on 1.85 to 1.90). Same arms, same order, same result.
- solidcore-convert and solidcore-ops depend on solidcore-record, which needs 1.88
  (edition 2024 let chains); they declared the workspace's 1.85, which cargo refuses.
  They now declare 1.88, like solidcore-tess.

With this, every package builds on the rust-version it declares
(scripts/ci/check-rust-version.sh): solidcore, solidcore-ssi, nurbs-grid-mesh and
xtask on 1.85; solidcore-record, -convert, -ops and -tess on 1.88. The whole
workspace also builds on 1.91, the reporter's toolchain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ops: pair the mitred rounds with as_chunks (Rust 1.88), as clippy asks now ops declares 1.88 (#3112)

With rust-version 1.88, clippy's MSRV-aware `chunks_exact_with_const_size` lint
fires on `mitred.chunks_exact(2)`. `as_chunks::<2>()` gives the same pairs as
`[EdgeId; 2]` arrays and drops an odd last one, so the arm for a short chunk goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci: the rust-version check fails when no package declares a rust-version (#3112)

If `cargo metadata` gives no package with a rust_version (a renamed field, a
jq filter that stops matching), the loop ran zero times and the check passed
with nothing checked. It now stops with an error instead.

The header says the scope is library and binaries on purpose (no
--all-targets: dev-dependencies may need a newer compiler than the package),
and the failure message says a dependency can be the cause too, pointing at
cargo's "requires rustc" line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ahmad Khan <khanahmad4527@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:41:14 -05:00
bherbruck bc63b3670b sweep: a multi-section blend through ordered cross-sections, G0 to G3 at its ends (#3140) (#13568)
* sweep: a multi-section blend through ordered cross-sections, G0 to G3 at its ends (#3140)

section_blend builds SolidCraft's loft_blend in the kernel: one B-spline surface through every station of ordered cross-sections. At the first and last section it holds the frames' conditions, G0 to G3. Interior sections hold the run's finite differences, C3 with four or more sections and C2 with three. Every span is one Bezier of Hermite rows, elevated exactly. That is SolidCraft's definition of the blend, documented as such, not an interpolant with an error against anything. end_rows, the Hermite-row builder, is public for the bridge to share. Tests against closed forms: two sections at G0 are the ruled surface; G1 ends leave at the definition's speed exactly; five sections of a parabolic cylinder with its own frames reproduce it; interior joints are C3 or C2; the refusals.

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

* sweep: section_blend grows its vectors as it fills them (panic-site inventory)

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

* sweep: section blend review: the backward sign, the cubic at u = 0.75 (#3194)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:41:10 -05:00
bherbruck dfb240a982 Setback beside curved faces: the spring curve stays inside its trimmed face (#2343 (d)) (#13571)
* test: a spring curve leaving its trimmed face names the setback that keeps it (#2343 (d))

The bored D prism (bore R 6.9) asked 8.5 at its top corner: the top
face's spring curve dips into the bore from asked 7.8068 (closed form,
in the test). Main refuses it late, "a round kept round a feature does
not close on the body", naming no size. Expected: SetbackTooLong at the
vertex naming 7.803 (0.918 x 8.5 on the grid of thousandths), whose
spring curve stays 3.3e-4 clear of the bore, and that setback builds.

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

* fix: a setback corner's spring curve stays inside its trimmed face (#2343 (d))

Beside a curved face the Setback built its spring curves exactly on
their faces but never checked them against the faces' other edges: an
asked setback whose spring curve ran into a bore was built and failed
later, naming nothing. The owner's ruling B on #2343 (d): check what
the corner takes out of the face.

Before the fill, each face's spring curve is made as the fill makes it
(spring_curve, from the rails' ends and their directions in closed
form, the ball's centre and the faces' normal fields) and kept clear of
every other edge of the face by more than the tolerances, decided on
control points (new blend_corner::on_face: hulls of rational Bezier
pieces, halved without recursion, undecided refuses). An asked setback
that fails is refused as SetbackTooLong naming the largest common scale
on the grid of thousandths at which every spring curve stays on its
face (bisect_thousandths, down to where the rails cross); the rule's
setbacks name their scaled setbacks; none: no setback builds. The
setbacks the growth finds are checked too.

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

* docs(findings): the bored D prism's spring curve against its bore (#2343 (d))

The measurement on main and the closed form behind the new test, with
a VectorCraft schematic of the top face: the spring curve at asked 8.5
dips into the bore, the one at the named 7.803 stays clear.

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

* fix: the spring-curve check reads every line and ellipse, and refuses what it cannot show (#2343 (d))

From the pre-ready review:
- a line or an ellipse edge that holds no B-spline yet gets one made on
  a copy (base_with_nurb, make_nurb), instead of counting as not clear;
- a rail whose direction is not found, or an edge without a curve, is
  not shown clear (refused), instead of letting the corner build
  unchecked; only a spring curve spring_curve refuses is left to the
  fill, which makes it from the same ends;
- the box gap's rounding is 2 gamma(1) of the coordinates, and the
  comments say where monotonicity is assumed, not proved.

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

* fix: the corner's neighbouring faces by checked remainders (#2343 (d))

The panic-site inventory (CI clippy job) flagged three `% n` in
corner_springs. A corner with no face is refused by checked_rem as a
null pointer instead of dividing by zero.

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

---------

Co-authored-by: Markus Reichart <52602624+MarkusReichart@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:31:28 -05:00
bherbruck a22d42ad98 query + ops: tangent chains on the kernel's proved smooth-edge test (#3141) (#13575)
* query: whether an edge's faces meet tangentially, proved, and the turn and convexity at a vertex (#3141)

query: tangent_edge's verdict is enclosed between its points; convexity read on the material side.

ops: tangent chains on record order, and the face chain across smooth edges.

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

* query: tangent_edge grows its vectors as it fills them (panic-site inventory)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:31:23 -05:00
bherbruck 7c03f57fd9 STEP: compose the product structure into placed bodies (#2578, slice 6) (#13582)
* STEP: the units of a representation context in solidcore-record (#2578)

SolidCraft's crates/step/src/units.rs and the Units type of its read.rs
at a5b71ca2 (MIT OR Apache-2.0), carried over as
solidcore_record::step::units on top of step::p21 with their behaviour
unchanged: the length and plane angle factors of
GLOBAL_UNIT_ASSIGNED_CONTEXT (SI prefixes, CONVERSION_BASED_UNIT chains)
and GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT's distance uncertainty in mm.
Missing or malformed units read as mm and rad; nothing fails. Only
multiplications, no disallowed methods.

Deviations: Units lives in units.rs; the conversion depth bound is the
named MAX_CONVERSION_DEPTH (was the literal 4).

Unit tests with synthetic Part 21 text, and an ignored corpus test that
tabulates the units of every geometric representation context.

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

* STEP: Part 42 curves as plain data in solidcore-record (#2578)

Slice 2b of #2578: SolidCraft's crates/step geom.rs and read.rs (a5b71ca2), the
curve part, into solidcore_record::step::{geom, read}, without arithmetic: the
reader maps entities to data (references, nesting and count limits, units) and
keeps directions as the file gives them. Normalising, the placement's
perpendicular and y axis, and a vector's length are not carried over; they come
back as kernel primitives in slice 4. Surfaces, surface curves and pcurves
follow in slice 2c.

Unit tests per curve kind and refusal with exact values; corpus_geometry
counts every model-space curve entity of the corpus, read or refused.

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

* STEP: Part 42 surfaces and pcurves as plain data in solidcore-record (#2578)

Slice 2c of #2578, on slice 2b: the surfaces of SolidCraft's crates/step
geom.rs and read.rs (a5b71ca2), the surface curves with their pcurves, and
curves in a surface's parameter space, into solidcore_record::step::{geom,
read}, without arithmetic beyond unit factors. A conic in a parameter space
needs equal factors on both parameters (was: equal within 1e-12 relative).

Fixes an unbounded recursion SolidCraft has: the depth bound was checked only
where a 2D curve was entered, so a 2D trimmed curve whose basis chain cycles
recursed until the stack overflowed; every curve read now checks it.
Reader::pcurve reads a PCURVE on its own.

Unit tests per surface kind, pcurve scaling and refusal with exact values;
corpus_geometry also counts every surface and every pcurve, read or refused.

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

* test: trims on a curve over a conic are angles, the failing case (#2578)

A TRIMMED_CURVE on an offset, a trimmed or a surface curve of a circle, in a
file in degrees, keeps its parameters unscaled today (CodeRabbit on #2592).

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

* STEP: a trimmed curve takes its parameter unit from its basis, through offsets, trims and surface curves (#2578)

The trimmed-curve path chose angles only for a circle or ellipse as the direct
basis; curve_param_kind already follows offset, trimmed and surface curves to
the conic underneath, as the revolution and extrusion profiles use it. Now both
rules are one. SolidCraft's reader has the same gap.

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

* STEP: the product structure as plain data in solidcore-record (#2578)

solidcore_record::step::product reads a parsed exchange structure into
products, product definitions, the shape representations they reach
(items: bodies by entity id, mapped items, others; context and units),
representation maps, relationships between representations with their
transformation as the file gives it (ITEM_DEFINED_TRANSFORMATION's two
placements, or an operator), assembly occurrences with their placements,
and styled items with their surface and curve colours (COLOUR_RGB as
written, ISO 10303-46 pre-defined colours from a fixed table).

No arithmetic beyond unit factors: composing placements is the kernel's,
sequenced by ops (slice 6). Cycles and nesting past MAX_ASSEMBLY_DEPTH are
refused; reading is linear, and the expansion of the assembly tree stops
past MAX_VISITS. Indices into id-ordered vectors, no HashMap order.

From the data walk of SolidCraft's crates/kernel/src/step_in/mod.rs at
a5b71ca2 (MIT OR Apache-2.0). corpus_product checks all 108 corpus files
against their entity counts.

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

* STEP: compose the product structure into placed bodies (#2578, slice 6)

The record's product structure keeps placements as the file writes them; this adds the kernel matrices and the ops walk that turn occurrences, mapped items and transformation operators into per-body world transforms. It emits one PlacedBody per body instance, leaving B-rep construction and display to their existing owners.

Stacked on #2645 (product data); slice 6 of #2578.

* STEP: keep the corpus harness compiling after the rebase

* Update crates/solidcore-record/tests/step_p21_corpus.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Markus Kaltenbrunner <153593707+kaltenbrunnermarkus@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: AlexMitchellMus <12004932+AlexMitchellMus@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-11-08 18:22:08 -05:00
bherbruck 91a08fc63a stitch: certify each closed gap and report the tolerance growth it applies (#2975) (#13592)
* stitch: report each closed gap and the tolerance growth its closure applies

Addresses review help wanted solidcore#2975: certify each closed gap.

- `heal::stitch::p2` (not upstream, on the stitcher): `ClosedGap` carries
  the closed entities (`Vertex`/`Edge`/`Squeezed`/`Split`), the gap the
  stitcher measured, and the tolerance growth the closure applied — read
  from the fused entity's tolerance before and after its own glue/squeeze.
  Collected only when the caller asks (zero cost off).
- `topology::brep::p4`'s `stitch_faces_with_gap_report` returns the batch;
  `topology::brep::p5`'s `stitch_and_orient_reported` exposes it over the
  same two passes. Nothing else the stitch does changes: no construction
  edit, and the growth does not feed back into the construction.
- `issue_2975_stitch_gap_report.rs`: two planar squares 1e-5 apart glue at
  both corners, each closure reporting gap == growth == 1e-5 (the glue
  keeps the closed distance inside the surviving tolerance), with the edge
  glue present; an exact match reports growth == 0 everywhere.

Evidence: fmt (1.98.0) and clippy `-D warnings --all-targets` clean;
`cargo test -p solidcore` whole-crate run green except an unrelated
`fillet_round_failure_notes_status::the_rollover_failure_notes_its_status`,
which reproduces on the clean base with the change stashed (pre-existing
on main, not this change's doing); `heal.stitch_p1` and `heal.stitch_p2`
goldens PASS 674 and 841 cases with zero changed/added/removed/broken;
wasm32-unknown-unknown check clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: make the exact stitch report assertion non-vacuous

The previous duplicate-face setup returned an empty report. Use two adjacent squares with an exactly shared boundary and require all three closures: two endpoints and their edge, with zero measured gap and zero growth.

Both report regressions and focused clippy -D warnings pass. This does not establish a whole-curve certificate: the reporting implementation still exposes sampled curve distance and certification remains unfinished.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>

* fix: enclose stitch gaps and roll back failed reports

Bound points and tolerance growth outward, use raw homogeneous interval de Boor enclosures across every knot span, and bound curve pairs by a full parameter correspondence. Equal positive rational bases use their control-point differences; line pairs use their endpoints. Unsupported curve kinds, malformed or non-positive weights, extrapolation and non-finite enclosures fail explicitly, never by substituting samples.

Measure each survivor against its own old tolerance, including inherited growth. Bound removed squeeze geometry and the original curve at each split parameter. The reported wrapper propagates errors regardless of legacy status flags and restores the full B-rep on failure. Unreported construction and sampled acceptance decisions are unchanged.

Local evidence: five enclosure controls and four report regressions pass, including an interior bump missed by five samples, rational and reversed/subdomain inputs, tiny/overflowing data, and failure after two vertex glues with full binary/arena-id rollback under flags off and on. Fmt passes. A clippy test-predicate style error is corrected; final lint, wasm, affected goldens and canonical exact-head CI remain pending. This stays draft.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>

---------

Co-authored-by: Alex Lytvynenko <altvnk@me.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: GPT-6.1 Sol <noreply@openai.com>
2026-11-08 18:12:53 -05:00
bherbruck 193a13d7fc mass: the volume's sign from a certified enclosure (#13593)
* mass: the volume sign's tests, failing on an enclosure stub (#2972)

`mass::enclosure` declares the certified enclosure of a body's volume
(`enclose_volume`, `volume_sign`, `VolumeSign::{Positive, Negative,
Undecided}`) and, for now, encloses nothing: every sign is undecided over
the whole line. The tests ask for enclosures that hold closed forms:

- a quarter shell R 1e4, t 1e-3, L 10 (V 157.08; the estimate at the sign
  test's accuracy 1e-3 reads -180.05) and R 10, t 1e-4 decide positive;
- the box 40x30x20 with one edge rounded r 3 is enclosed within 1e-6 V
  around 24000 - 360 (1 - pi/4), default kernel and round definitions on;
- a box decides +120, its inside-out twin -120; a sphere r 2 holds
  4/3 pi 8 within 1e-6.

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

* mass: enclose a closed body's volume and decide its sign (#2972)

`enclose_volume` and `volume_sign` replace the quadrature's estimate by
an enclosure [lo, hi] of the same flux, F = (0, 0, z - z0), face by face
as `brep_properties` integrates it (Green's theorem on each face, the
trims swept down to a base):

- each trim piece (a pcurve's polynomial piece, or a uv segment closing
  a loop) is swept to the base piecewise between the surface's v knots,
  and each cell of (t, sigma) is a Taylor model: the integrand's Taylor
  polynomial of degree 7 at the cell's middle, its coefficients enclosed
  there and integrated exactly, plus the Lagrange remainder from the
  degree-8 coefficients enclosed over the cell (rounded intervals,
  `curve_derivs_over`, and the new `nurbs_partials_within_span`, which
  cuts the weight's enclosure to the piece's control weights where the
  box lies in its span);
- a cell whose image may cross a knot line is enclosed by its range,
  the integrand in its factored form -(S_z - z0) (S_u x S_v)_z u' b, so
  a trim along a knot line in u (u' near 0) stays near 0; a cell whose
  weights are not proved positive is cut along t and sigma in turn;
- the faces' trimmed boundaries are joined across each edge by the ruled
  strip between two of its face sides of opposite sense (two at a
  manifold edge, four where solids touch), bounded from their enclosed
  distance (not from the edge tolerance, which is sampled), and what is
  left open at the vertices by cones of area <= L^2 / 4; a closing
  segment along a pole (to within rounding of the net's last knot) is
  bounded through the pole row's spread;
- each cell is first evaluated at remainder degree 3 and again at 8
  when it is the widest, before it is cut (a body that decides at once,
  as most do, costs little: the 21 sign decisions of the rig's Menger
  sponge 62 s -> 10.6 s);
- the widest cell is bisected until the enclosure is as narrow as asked
  or decides the sign (lo > 0, hi < 0), within 2^17 cells, no recursion,
  the cancel token polled; `Undecided` only when 0 is still inside.

On the tests' bodies: box 4x5x6 [119.99999999988, 120.00000000012],
the rounded box [23922.7378, 23922.7489] around 23922.7433, the G2
round (387 cells) positive, sphere r 2 within 1e-6 of 33.5103216383,
the quarter shell R 1e4, t 1e-3 (V 157.08, the estimate at 1e-3
-180.05) positive, nurb_fillet.smb positive.

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

* topology: the gate and is_outward_solid read the certified volume sign (#2972)

The local operation's soundness gate and `is_outward_solid` decided a
body's volume sign from `brep_volume_default` (at 1e-6 and 1e-3), an
estimate. Both now read `mass::enclosure::volume_sign`:

- `validation::volume` becomes `validation::volume_sign`, and
  `Soundness::volume` holds the sign; a body is sound only where its
  volume is proved positive;
- the gate refuses a negative enclosure as `VolumeNotPositive`, and an
  enclosure that still holds 0 after its budget as the new
  `Unsoundness::VolumeUndecided`;
- `is_outward_solid` (and `is_outward_touching_solids`) is true only for
  a proved positive volume; `SIGN_ACCURACY` goes.

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

* mass: the enclosure's bound sums and widths, with a test that they round up (#2972)

The strip, cap and rounding-gap bounds add magnitudes (`mag3`) and take
widths `hi - lo` in plain f64, and round-to-nearest can put the result
below the true sum (CodeRabbit on #3006). `width` gathers the widths;
`bounds_round_up` asks both for a value above 1 where 1 + 1e-16 rounds
to 1. It fails here: both still round to the nearest.

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

* mass: the enclosure's bound sums and widths round up (#2972)

`mag3` sums the magnitudes in `Ivl` and `width` takes `hi - lo` in
`Ivl`, each returning the upper end. So the strip and cap lengths, the
rounding gaps' terms, a closing segment's length and the pole row's
spread are bounds again, as the module's contract says, not values half
an ulp below one (CodeRabbit on #3006). `bounds_round_up` passes.

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

* mass: tests for an undecided sign and an unreadable face (#2972)

From jyviko's review of #3006:
- the quarter shell R 10, t 1e-6 passes check_brep level 2, but its
  faces' boundaries lie ~1e-6 apart, as far as its volume (1.57e-4):
  its sign is undecided, `is_outward_solid` reads that as not a solid,
  and the local operation's gate refuses it as `VolumeUndecided`. The
  integration test asks the decision to end below a quarter of the
  budget; it fails here (131072 cells, 131054 of them strips, which
  cutting does not narrow);
- a box with an offset-surface face is an `Unsupported` error, not a
  sign;
- `nurbs_partials_within_span` over a box across the span's knot is
  `nurbs_partials_over_piece`'s enclosure.

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

* mass: an undecided sign ends where no cut can decide it (#2972)

A strip cell whose `int |D|` is at least half the gap's least size over
it (at its middle, less its slope's bound times the half length, in the
norm `int |D|` is taken in) is settled: a real gap between two faces'
boundaries, which cutting does not narrow. Settled cells are kept as
they are; when their half width with the fixed terms reaches the
magnitude of everything else, 0 stays inside whatever the rest becomes,
and the sign is `Undecided` at once. The quarter shell R 10, t 1e-6
ends after 24236 cells instead of 131072.

A unit test spends a budget of 16 cells (production keeps
`CELL_BUDGET`). `is_outward_solid` says that an undecided sign is "not
a solid" on purpose, and which bodies are an error instead (a face with
no NURBS form, without trim loops, or weights not proved positive);
docs/validity-and-tolerances.md §4 reads the certified sign. The
`Cells.` paragraph of the module doc is wrapped.

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

* docs: invariant 2 carries the volume sign's exception until the enclosure widens by epsilon (#2972)

The solid test and the local operation's gate read a certified face on its
net B (jyviko's option 1 on #2972); the wording is MarkusReichart's
proposal on the PR, to be confirmed by jyviko.

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

---------

Co-authored-by: Markus Reichart <52602624+MarkusReichart@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-11-08 18:12:48 -05:00
bherbruck 2e8e68c64d convert: parity with SolidCraft's flat hop on the corpus; lines as the kernel holds them, tolerances as the flat import took them, Order's pieces (#3137) (#13570)
* convert: parity with SolidCraft's flat hop on the corpus; lines as the kernel holds them, tolerances as the flat import took them, Order's pieces (#3137)

Every operand record of the rig corpus is the flat export's output. Each goes to the kernel and back and is compared bit for bit (`tests/flat_parity.rs`, the whole corpus): 1,256 of 1,332 bodies come back identical, 37 within about 2 ulp, and 39 differ only in named classes.

- `record_of` writes a line as the kernel holds it: its point, point plus vector, over the STEP parameters of the edge's ends (`FlatFromBrep`'s form), not vertex to vertex over [0, 1]. That reparameterised every line and its pcurves, in 711 bodies.
- `to_kernel` keeps each edge's and vertex's own tolerance, the body's and each face's the least of theirs, as the flat import's `own_tolerances` does, instead of raising entities to the body's.
- `Order` gains its inverse maps and `pieces`, SolidCraft's `history::Piece`: the record edges of a kernel edge solids touch along.

Part of #3137

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

* convert: pin flat_parity's differences per body; own_tolerances keeps invariant 5 and refuses an underflowed zone (#3187 review)

flat_parity pins every differing body (40) to exactly its classes and
asserts the totals (1332 / 1256 / 36), the rounding class relative with no
floor (largest 4.885e-16, asserted), .tolerance only lowered to the least
of the record's own, and the sheet-side bodies' geometry the same as
multisets. own_tolerances documents invariant 5 (validity-and-tolerances
§10) and returns Invalid where the least zone underflows to 0 instead of
falling back silently. Tests: a body tolerance above its entities comes
down to theirs, valid at Level 2; an underflowed zone is refused; a reversed
line goes to the record with its vector turned; pieces' faces are their
record edge's uses.

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

* test: the seam case inflates every stored tolerance, not only the body's (#3187 keeps each edge's own)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:05:37 -05:00
bherbruck 11d408d8c2 tess: the pole classifier runs once per surface, not per evaluated point (#3152) (#13559)
* solidcore-tess: ask the pole classifier once per node, not once per look

The collapsed directions at a node are a property of its parameters, but sag
refinement, the flip pass and the sag measure asked the kernel's classifier
again at every triangle corner, side and round (and the self-crossing check
twice per node). A Poles memo, indexed by node and shared by the three passes
of a face, asks it once per node.

Classifier calls on the smlib coil half (3 faces), display / export:
1,089,434 / 1,091,452 before, 42,846 / 42,654 after. Triangle counts unchanged
(30,776 / 30,808); the meshes are the same values, only the repeated question
is gone. Regression test: the_pole_classifier_is_asked_once_per_node_of_a_spline_face.

Refs #3152

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

* solidcore-tess: count the classifier through the memo, no thread-local

The test counter was a thread_local in the library crate. Poles now carries a
test-only call counter; the test refines, flips and measures a coarse
cylinder patch and asserts at most two classifier calls per node. With the
memo's lookup removed (the old behaviour) it fails: 25,852 calls for 349
nodes. A second test keeps the coil's triangle counts (30,776 / 30,808).

Refs #3152

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:58:01 -05:00
bherbruck 1b4547f2b3 goldens: the five extrusion defeature cases are no longer known failing, and are blessed (#3093) (#13560)
#3176 made the extension's mirror step reflect on the knot it lands beside, so the
add-by-faces extrusion builds the same closed solid whatever the last bits of the
extended range. The five cases listed under #3093 in known_failing.json
(add_by_faces_extrusion, attributes_add_by_faces_extrusion, multi_feature_extrusion,
multi_feature_adv_extrusion, multi_rebuild_0_extrusion) are removed from it and their
golden lines added (a listed case has no golden line; a fixed one must).
single_face_fillet1019_6 also cites #3093 but is a different defect (check_brep level 2
on a fillet), is not shown fixed, and stays listed.

Evidence: the soundness probe on #3176 builds all five closed and manifold, 13 faces,
27 edges, 20 vertices, volume 788.9154262527, 0 check_brep lines. The check run of the
unit lists exactly these five as added and the other three extrusion cases unchanged.
The lines were produced on aarch64; CI's x86 run is the reference and any line it
lists as changed is taken from it.


Claude-Session: https://claude.ai/code/session_01LUXeKMptsUdYotbrqYpSda

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:57:56 -05:00
bherbruck ae4f93a7cf sphere: a far sphere's meridian angles are held to their rounding at the points' size (#13565)
* sphere: a far sphere's meridian angles are held to their rounding at the points' size

`is_nurb_surface_sphere` compares the half meridian with 180 degrees, and the circle's STEP interval with the sphere's, within fixed degree tolerances. A sphere of radius 2.5 at 1e5 reads 180 + 6.67e-10 degrees and was not recognised (#3131). The angles are read off points known to gamma_K |P| (K = 16 (p + q + 2), the bound `evaluates_as` uses) and a centre fitted to them, so each is now allowed 4 gamma_K |P| / r radians more. It is recognised from 0 to 1e6, and a label or form 1e-3 off is refused at every offset.

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

* sphere: one allowance for the meridian's angles, in the recogniser and its rebuild, tested at its size

#3196 review. `meridian_rounding` is the one bound for the half meridian and the STEP interval in `is_nurb_surface_sphere` and for the half meridian in `rebuild_step_from_nurb_parameters` (which kept upstream's fixed degrees). K comes from the form's degrees; the fitted centre's share is measured, not derived, and the doc says so (largest excess 0.0055 of the allowance, radii 1e-3 to 1e3, offsets 0 to 1e7, turned spheres). Tests: a meridian 0.4 of the allowance past 180 degrees is recognised and twice it refused, at the origin and 1e5 (a meridian d past folds the STEP interval by 2d); the allowance against its closed form (1.8e-11 degrees at the origin, 9.77e-8 at 1e5); recognition across radius, offset and orientation. The header records the default change.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:57:48 -05:00
bherbruck 59b78db068 extend: certify_iso takes its rounding at the iso's own size again (#13567)
* extend: certify_iso takes its rounding at the iso's own size again

f99618c27a sized the iso's rounding from the net's rows, which widened the limit. With every row of a turn at one weight ratio (c74d421fbf), off / limit at the iso's own size is at most 0.0123 over 1004 goldens calls, 0.0079 in the extend tests, 0.0059 in the 500 and 1500 mm regression, and 0.0045 over 192 calls on SolidCraft's pleats: the 500 mm refusal was the row ratios' noise, not the bound. The widening is reverted (#3098, #3068).

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

* extend: certify_iso's limit is held to the iso's own size by a test, and labelled tuned

#3195 review. The limit's factor, 8 gamma_32, counts no operations: the doc now says it is tuned, not derived, and measured within it (largest off / limit 0.0123). A unit test pins its size: one point of a parallel on a cylinder 1000 long, off its plane by twice the limit at the point's own size, is refused, and by half of it is accepted; a limit at the rows' size would accept both. A cylinder of radius 1 extended 1e2 to 1e5 along its rulings stays on its cylinder: the limit at the iso's size holds as the extension outgrows the source.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:57:44 -05:00
bherbruck 9210343c9d kernel rig: check every pick before reporting a traced miss (#2993) (#13552)
* test: a traced miss before a malformed pick must not hide the bad data (#2993)

`a_traced_miss_is_unsupported_only_among_well_formed_picks`: `[{miss}, {index: 999}]`,
`[{index: 999}, {miss}]`, a miss before an empty pick and before a pick that both hits
and misses fail; `[{index: 0}, {miss}]` is unsupported. Fails on main at the first list.

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

* kernel rig: check every pick before reporting a traced miss (#2993)

`picked` collected with `collect`, which stops at the first `Err`, so a traced miss
before a malformed pick graded the case unsupported. It now checks each pick's shape
and range first and reports the miss only after the whole list is well formed.
`record_picks`, `record_pick` and the arms that call them go through `picked`.

Fixes #2993

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

---------

Co-authored-by: Werner1303 <102161652+Werner1303@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:48:36 -05:00
bherbruck cce1c9dcf5 fix: 72 open panic sites in blend files no open PR edits (#2490, slice 3) (#13553)
* fix: 16 open panic sites in blend files no open PR edits (#2490, slice 3)

Each cannot panic and is marked proven with its guard: an index from
position() or enumerate() on the same Vec with nothing changed before it,
a bounds check or early return just above (dual_run, move_tsp, topology_split),
a while loop whose every increment continues, an enumerate() over the Vec
whose length divides, or a capacity read from an in-memory collection.

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

* fix: five more open panic sites in blend files, each inside a loop over its divisor (#2490, slice 3)

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

* fix: 14 more open panic sites in fillet_corner/p3.rs and fillet_geom/p4.rs (#2490, slice 3)

Each guarded by a check that returns above, an index found in the same Vec,
a branch that fixes the divisor at 4, or an in-memory length.

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

* fix: 29 more open panic sites in blend corner files, each with its divisor shown non-zero (#2490, slice 3)

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

* fix: 8 more open panic sites in blend files: in-memory capacities, a constant, two checked divisors (#2490, slice 3)

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

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:48:31 -05:00
bherbruck 11bb98683f fix: 28 open panic sites in surface and boolean files no open PR edits (#2490, slice 3) (#13573)
* fix: 25 open panic sites in surface files no open PR edits (#2490, slice 3)

Cone::evaluate joins its rows with concat, which sizes the result from the
rows evaluate_rows built, so its with_capacity site is gone. The other 24
sites cannot panic and are marked proven with the guard that rules each out:
an early return (cone ellipse trims, extrusion plane cut, grid row size), an
enclosing for loop over the divisor (plane ellipse, step-window unwind), an
in-memory length (step-window split, cache splits), a checked order bound
(srf_in_volume) or a constant divisor (cache block halving).

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

* fix: three open panic sites in boolean files no open PR edits (#2490, slice 3)

merge/p2.rs MergeState::merge_added_body and topology_intersector/p2.rs
teardown_reads_freed remove an index position() just found in the same Vec;
TopologyIntersector::intersect_topology sizes a Vec from an in-memory
slice. All three are marked proven with that guard.

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

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:48:16 -05:00
bherbruck 55297e5b30 solidcore README: developed inside SolidCraft; this repo is a mirror (#13627)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:48:11 -05:00
Charles Galant b992941d5d fix(topology): flip moved inner loops when DeleteEdge flips surviving face (WR-759)
Fixes #2927.

When delete_manifold_edge (upstream SmBrep::DeleteEdge, SmBrep.cpp:5872-5873, 6194-6197, 6524-6530) merges an outer loop with an inner loop across two coplanar faces of opposite surface normals where surface_to_keep belongs to del_f, it swaps the faces' surfaces and calls flip_face_orientation on surv_f, inverting surv_f's primary faceuse orientation so it matches del_f's. Because same_orientation_faces was never updated after that flip, moving del_f's remaining inner loops onto surv_f flipped them onto the wrong faceuse.

On flanged_spool.step cut at z > 130, merging the tool box's +Z cap face with the spool's -Z step face across r = 35 inverted the r = 31 bore hole on the merged cap face, failing SmBrep Pointer Test 98 twice and inflating the integrated volume from 217496.967 to 609976.137 mm^3.

Toggle same_orientation_faces when flip_face_orientation returns Ok(()), record WR-759 in plan/upstream-issues/, add standalone regression wr759_delete_edge_flipping_surviving_face_preserves_moved_inner_loop_orientation in crates/solidcore/src/topology/brep/p4.rs, add z = 130 / 130.0 bore-step coverage in crates/step-brep/tests/issue_2927_spool_boolean.rs, and add rig case booleans/2927-flanged-spool-cut-at-bore-step.

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-08 16:34:22 -05:00
Charles Galant 23c8f45798 fix(step-brep): normalize STEP face outer bounds and vertex-touching loops (#2927)
Order FACE_OUTER_BOUND first when present (rejecting >= 2 outer bounds); when omitted on a multi-bound face, select the outer loop on planar faces via solidcore's Curve::calculate_tight_bounding_box (erroring on ties within EFF_ZERO), classify open non-periodic sheets via solidcore's topology::face::p3::compute_loop_orientation_3d (requiring a unique CCW UV outer loop enclosing all interior bounds within EFF_ZERO_PARAM), and refuse periodic/closed surfaces (cylinders, cones, spheres, tori, surfaces of revolution) rather than guessing.

Splice vertex-touching loops on the same face into single Eulerian material-on-left boundary cycles in <= m - 1 merges so SmFace::AssertValid Face Test 39 (Loops Share Vertex) is satisfied in both BuildMode::Default and BuildMode::StitchOnly.

Adds integration suite crates/step-brep/tests/issue_2927_spool_boolean.rs (evaluating closed-form / Gauss-Legendre reference volumes, uncut-spool B-spline chamfer residuals via OdeIsoCurveLength z-divergence, Level2 validity, additivity, mirror symmetry, and Lipschitz continuity across the 25 non-bore-step #2927 cuts plus 6 exterior boundary cuts and 5 fine collar cuts) and rig case booleans/2927-flanged-spool-cut-through-axis.

Part of #2927.

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-08 16:34:22 -05:00
494 changed files with 68597 additions and 9154 deletions
+17 -5
View File
@@ -1,15 +1,27 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
profile: chill
poem: false
sequence_diagrams: false
related_issues: false
related_prs: false
suggested_labels: false
suggested_reviewers: false
in_progress_fortune: false
auto_review:
# Review when a PR is opened or marked ready, or on `@coderabbitai review`; not on every push.
drafts: false
auto_incremental_review: false
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
path_instructions:
- path: "**"
instructions: |
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
Project rule: don't change anyone's existing UX defaults unless the change is purely additive and belongs in the application.
A changed default is when something people already rely on in the UI changes for everyone who didn't opt in: an existing control, interaction or setting value. Examples the owner declined or reverted: right click becoming select while left click orbits (make it opt-in instead); a PR that removed all camera animation when only orbit lag was to be fixed. Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut.
A changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default; don't flag it as one, and expect `Changes a default: no` for it.
Not defaults: bug fixes and more correct results, kernel or geometry result changes, new options that are off by default, and CI or tooling config. Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
# Where code goes, SolidCraft or SolidCore: solidcore/docs/layers.md.
@@ -177,5 +189,5 @@ reviews:
- name: "Existing defaults unchanged"
mode: "warning"
instructions: |
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
Pass if the pull request changes no UX default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts), or if its changes are purely additive. UX defaults only: a changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default and passes.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes a UX default.
+2 -1
View File
@@ -4,4 +4,5 @@
# The vendored SolidCore kernel, owned as in SolidCore's own CODEOWNERS
# (solidcore/.github/CODEOWNERS, which GitHub reads only at a repository's root).
/solidcore/ @jyviko @bherbruck
# The team (jyviko and bherbruck) assigns one of them per pull request, so either one approves.
/solidcore/ @tangency-labs/solidcore-owners
+1 -1
View File
@@ -6,7 +6,7 @@ Start with what this is, in plain words someone new to the project would underst
Changes a default: no
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour; a UX default only, a kernel result change is 'no'):
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
+32 -1
View File
@@ -1040,7 +1040,7 @@ jobs:
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm, solidcore]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm, solidcore, solidcore-standalone]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -1100,6 +1100,7 @@ jobs:
WASM: ${{ needs.wasm.result }}
SOLIDCORE_RUN: ${{ needs.changes.outputs.solidcore }}
SOLIDCORE: ${{ needs.solidcore.result }}
SOLIDCORE_STANDALONE: ${{ needs.solidcore-standalone.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -1140,6 +1141,7 @@ jobs:
# solidcore/** changed on an approved pull request, in a merge group or on main.
if [ "$SOLIDCORE_RUN" = true ]; then
test "$SOLIDCORE" = success
test "$SOLIDCORE_STANDALONE" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
@@ -1175,6 +1177,35 @@ jobs:
# solidcore/** (Select checks' `solidcore`), where `ci` requires it; skipped otherwise. Its build,
# kernel-rig and goldens jobs are one entry here, so the rig and goldens binaries are built once
# without an artifact between jobs.
# solidcore/ exported alone (as the mirror to tangency-labs/solidcore carries it) must still check: the
# `layers` rule reads the manifests, this builds them without the rest of the repository around.
solidcore-standalone:
name: SolidCore builds on its own
needs: changes
if: needs.changes.outputs.solidcore == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
env:
CARGO_INCREMENTAL: 0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Export solidcore/ alone
run: |
mkdir -p "$RUNNER_TEMP/sc"
git archive HEAD solidcore | tar -x -C "$RUNNER_TEMP/sc"
- name: Toolchain (solidcore/rust-toolchain.toml)
working-directory: ${{ runner.temp }}/sc/solidcore
run: rustup show active-toolchain || rustup toolchain install
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: "${{ runner.temp }}/sc/solidcore -> bin"
key: solidcore-standalone
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Check the exported tree
working-directory: ${{ runner.temp }}/sc/solidcore
run: cargo check --workspace --all-targets --locked
solidcore:
name: SolidCore checks (${{ matrix.check }})
needs: changes
+1 -1
View File
@@ -20,7 +20,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
- A changed default (existing UI, not kernel results) keeps the old behaviour behind a preference; fill the PR's `Changes a default:` line.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
+5 -3
View File
@@ -25,8 +25,10 @@ solidcore kernel, following [`solidcore/docs/layers.md`](solidcore/docs/layers.m
`skills/solidcraft-layers/SKILL.md`. SolidCraft's own `crates/kernel`, `crates/smlib` and
`crates/geom` only shrink toward the seam (ops, tess, record); please don't grow them.
A kernel-only pull request targets the `kernel` branch. A change that touches the kernel and the
app goes to `main` as one pull request, with a code owner approving the `solidcore/` part.
A kernel pull request targets `main` like any other, with a code owner (jyviko or me) approving the
`solidcore/` part; a change that touches the kernel and the app is one pull request. The `kernel` branch
is only for multi-step kernel work that isn't safe on `main` until every step has landed: send those
steps to `kernel`, and it merges into `main` when the work is done.
Crashes and data loss are still taken first.
### Updating a PR that was moved from solidcore
@@ -448,7 +450,7 @@ comments only when they're about crashes, wrong results, data loss or security.
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
fails when a lower-numbered open PR by someone else already closes the same issue (the label
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt). A default here is a UX default only: a control, setting, preference or tool behaviour a user sees by default; a changed kernel or geometry result is `Changes a default: no`.
## Labels and triage
Generated
+235 -12
View File
@@ -125,10 +125,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -143,6 +154,21 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -485,6 +511,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -516,6 +551,16 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -703,7 +748,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
]
[[package]]
@@ -768,7 +822,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
"inout 0.1.4",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.6",
]
[[package]]
@@ -887,6 +951,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -996,9 +1066,9 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"aes 0.8.4",
"block-padding 0.3.3",
"cbc 0.1.2",
"dbus",
"fastrand",
"hkdf",
@@ -1095,6 +1165,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecb"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ecolor"
version = "0.37.0"
@@ -1625,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
"weezl 0.1.12",
]
[[package]]
@@ -2068,10 +2147,20 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"block-padding 0.3.3",
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2316,6 +2405,43 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lopdf"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
dependencies = [
"aes 0.9.4",
"bitflags 2.13.2",
"brotli-decompressor",
"cbc 0.2.1",
"ecb",
"encoding_rs",
"flate2",
"getrandom 0.4.3",
"indexmap",
"itoa",
"log",
"md-5",
"nom",
"rand 0.10.3",
"rangemap",
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2508,6 +2634,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -2773,6 +2908,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -3311,6 +3456,7 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@@ -3355,6 +3501,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "rangemap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3730,8 +3882,8 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"hkdf",
@@ -4325,6 +4477,7 @@ dependencies = [
"image",
"moxcms",
"png",
"rayon",
"robust",
"serde",
"serde_json",
@@ -4332,8 +4485,9 @@ dependencies = [
"solidcore-record",
"solidcraft-color",
"solidcraft-geom",
"sysinfo",
"web-time",
"weezl",
"weezl 0.1.12",
]
[[package]]
@@ -4402,6 +4556,7 @@ dependencies = [
"egui-wgpu",
"image",
"log",
"lopdf",
"minisign-verify",
"muda",
"nix 0.31.3",
@@ -4506,6 +4661,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4557,6 +4723,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "sysinfo"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4629,7 +4809,7 @@ dependencies = [
"flate2",
"half",
"quick-error",
"weezl",
"weezl 0.1.12",
"zune-jpeg",
]
@@ -4668,6 +4848,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
@@ -4845,6 +5031,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.5"
@@ -5226,6 +5421,12 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "weezl"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -5409,6 +5610,22 @@ dependencies = [
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -5418,6 +5635,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
+2
View File
@@ -69,6 +69,8 @@ semver = "1"
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0).
spade = { version = "2.15", default-features = false, features = ["std"] }
rayon = "1.12"
# Native render-memory admission; RAM-only queries through a safe MIT-licensed API.
sysinfo = { version = "0.40.1", default-features = false, features = ["system"] }
# packages (3MF)
zip = { version = "9", default-features = false, features = ["deflate"] }
+654 -113
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -76,7 +76,7 @@ const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect
/// `snapshot`'s lines in the usage.
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.sc|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27] [--quality coarse|normal|fine|ultra]\n solidcraft-cli snapshot --in <design.sc|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON;\n --quality: the display quality the bodies are meshed at, as the app's setting (default normal))\n";
/// `render`'s lines in the usage.
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted] [--legacy-memory-admission]\n --legacy-memory-admission skips available-RAM checks; fixed shared/host budgets and allocation limits still apply.\n";
/// `exec`'s lines in the usage.
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
/// `commands`'s lines in the usage.
+11 -3
View File
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
use solidcraft_engine::{
Session,
doc::render_settings::{RenderJob, RenderSetup},
render::job::{CpuHost, JobId, Phase, ToneMap},
render::job::{CpuHost, JobId, NativeMemoryPolicy, Phase, ToneMap},
};
/// Only explicit runtime CLI flags are stored here. A typed setup owns these
@@ -74,6 +74,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
let mut height = None;
let mut exposure = None;
let mut runtime = RuntimeOptions::default();
let mut memory_policy = NativeMemoryPolicy::AvailableRam;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
@@ -82,6 +83,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
"--progressive" => runtime.progressive = true,
"--transparent" => runtime.transparent = true,
"--overwrite" => overwrite = true,
"--legacy-memory-admission" => memory_policy = NativeMemoryPolicy::SoftwarePoolOnly,
"--out" | "--setup" | "--width" | "--height" | "--timeout" | "--samples" | "--max-depth" | "--seed" | "--exposure" | "--lighting"
| "--tone-map" | "--environment" => {
i += 1;
@@ -134,9 +136,15 @@ pub fn run(args: &[String]) -> Result<(), String> {
// Resolve saved intent before opening a GPU or explicitly settling geometry.
// Width/height/exposure only change this validated inline settings clone.
let (mut params, needs_gpu) = runtime.request(&session, settings)?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
// The machine admission policy may accompany any saved setup; it does not
// replace the setup's image-quality options or alter the saved document.
let cpu_host = CpuHost::for_platform().map_err(|e| e.to_string())?;
cpu_host.set_native_memory_policy(memory_policy);
session.set_render_job_host(Some(cpu_host)).map_err(|e| e.to_string())?;
if needs_gpu {
session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let gpu_host = solidcraft_gpu::path::host().map_err(|e| e.to_string())?;
gpu_host.set_native_memory_policy(memory_policy);
session.register_render_job_host(gpu_host).map_err(|e| e.to_string())?;
}
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
if setup.is_none() {
+11 -1
View File
@@ -152,7 +152,17 @@ fn render_help_keeps_the_registered_gpu_request_controls() {
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty());
let stdout = String::from_utf8_lossy(&output.stdout);
for flag in ["--progressive", "--samples", "--max-depth", "--seed", "--exposure", "--lighting", "--environment", "--tone-map"] {
for flag in [
"--progressive",
"--samples",
"--max-depth",
"--seed",
"--exposure",
"--lighting",
"--environment",
"--tone-map",
"--legacy-memory-admission",
] {
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
}
}
+10 -2
View File
@@ -673,6 +673,7 @@ fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
.unwrap(),
);
assert_eq!(result["preflight"]["resolved"]["executor"], "cpu-raytrace");
assert_eq!(result["preflight"]["admission"]["host_memory"]["policy"], "available_ram");
assert_eq!(result["export"]["frame"]["executor"], "cpu-raytrace");
assert_eq!(result["preflight"]["request"]["integrator"], "raytrace");
assert_eq!(result["preflight"]["request"]["raytrace"].get("progressive"), options.get("progressive"));
@@ -689,11 +690,12 @@ fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
.arg(&saved)
.args(["--setup", "Portable setup", "--out"])
.arg(&overridden)
.args(["--width", "12", "--height", "8", "--exposure", "1.25"])
.args(["--width", "12", "--height", "8", "--exposure", "1.25", "--legacy-memory-admission"])
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-cpu")
.output()
.unwrap(),
);
assert_eq!(result["preflight"]["admission"]["host_memory"]["policy"], "software_pool_only");
assert_eq!(result["preflight"]["request"]["width"], 12);
assert_eq!(result["preflight"]["request"]["height"], 8);
assert_eq!(result["preflight"]["request"]["raytrace"]["exposure_ev"], 1.25);
@@ -773,7 +775,13 @@ fn typed_gpu_render_selects_its_host_without_a_progressive_runtime_flag() {
let script = render_setup_script(&dir, job.clone());
let out = dir.0.join("gpu.png");
let mut command = cli();
command.arg("render").arg(&script).args(["--setup", "Portable setup", "--out"]).arg(&out).env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
command
.arg("render")
.arg(&script)
.args(["--setup", "Portable setup", "--out"])
.arg(&out)
.arg("--legacy-memory-admission")
.env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
if job.is_null() {
command.arg("--progressive");
}
+3 -3
View File
@@ -360,7 +360,7 @@ mod web {
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand script fonts: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font: solidcraft_ui_egui::ui_kit::type_face::CjkFontLoader,
font_result: FontLoadResult,
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
@@ -1059,7 +1059,7 @@ mod web {
}
};
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
if !installed && solidcraft_ui_egui::ui_kit::type_face::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
@@ -1093,7 +1093,7 @@ mod web {
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
/// fetch the Japanese font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
if self.font.watching_text() && solidcraft_ui_egui::ui_kit::type_face::painted_cjk(ctx) && self.font.text_needs_font() {
self.fetch_font(ctx, "ja");
}
}
+1 -1
View File
@@ -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(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let galley = painter.layout_no_wrap(label, crate::ui_kit::type_face::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);
+59 -1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "إيقاف السجل",
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.ui.palette": "البحث في الأوامر",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "مستعرض التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
@@ -2431,6 +2432,7 @@
"parts.dimension.width": "العرض",
"parts.family.bearing": "محمل كريات أخدودي عميق (الغلاف)",
"parts.family.button_head_cap_screw": "برغي برأس زرّي بتجويف سداسي",
"parts.family.countersunk_socket_screw": "برغي برأس غاطس بتجويف سداسي",
"parts.family.dowel_pin": "مسمار توجيه أسطواني",
"parts.family.hex_bolt": "برغي سداسي الرأس",
"parts.family.hex_cap_screw_inch": "برغي سداسي الرأس (بوصة)",
@@ -2826,6 +2828,8 @@
"prefs.remember_dialog_values.help": "معطّل افتراضيًا. عند تفعيله، بعد نجاح الأمر، يُعاد استخدام قيمه وخياراته الآمنة في المرة التالية التي تفتح فيها الأمر نفسه، حتى إغلاق SolidCraft.",
"prefs.render_legacy_image_defaults": "استخدام الإعدادات الافتراضية السابقة لصور التصيير",
"prefs.render_legacy_image_defaults_help": "الاحتفاظ بسير العمل اليدوي السابق: إعدادات جديدة بالتصيير النقطي على CPU، وأخذ العينات الثابت لتتبع الأشعة على CPU، وفتح النتائج يدويًا. إعادة ملاءمة العرض عند دخول التصيير، وتبديل اللقطات دون استعادة كاميراتها، واستخدام التأطير والتنقل السابقين. تعطيل التحديث التلقائي للصورة المباشرة. لا تتغير الإعدادات المحفوظة.",
"prefs.render_legacy_memory_admission": "استخدام فحوصات ذاكرة التصيير السابقة",
"prefs.render_legacy_memory_admission_help": "السماح بالتصيير المحلي ضمن حدود الذاكرة المشتركة وحدود المضيف الثابتة حتى عندما يتجاوز التقدير الذاكرة المتاحة. تظل حدود الجهاز وفحوصات تخصيص الذاكرة سارية.",
"prefs.render_previous_geometry_budget": "استخدام ميزانية الهندسة السابقة لمعاينة التصيير",
"prefs.render_previous_geometry_budget_help": "استعادة حد الهندسة الثابت السابق (512 ميبيبايت على سطح المكتب 64 بت، و128 ميبيبايت على المنصات الأخرى) بدلًا من الميزانية المعتمدة على الجهاز.",
"prefs.reports_saved": "{n} محفوظة",
@@ -3220,8 +3224,10 @@
"render.category_paint": "طلاء",
"render.category_plastic": "بلاستيك",
"render.category_wood": "خشب",
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
"render.caustics_depth": "عمق مسار تجمّع الضوء",
"render.caustics_enable": "تجمّع الضوء عبر الزجاج من مصادر نقطية واتجاهية",
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
"render.caustics_help": "يتحكم عدد الفوتونات في الضجيج وكلفة الحساب. يوازن نصف قطر التجميع بين التفاصيل الدقيقة والتقدير الأنعم. يتطلب CPU إضاءة غير مباشرة.",
"render.caustics_path_required": "يتطلب تجمّع الضوء على CPU إضاءة غير مباشرة.",
"render.caustics_photons": "عدد الفوتونات",
@@ -3398,7 +3404,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–64). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_lighting": "إضاءة غير مباشرة",
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_point": "ضوء نقطي",
@@ -3438,6 +3444,12 @@
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
"render.cpu_threads": "خيوط المعالجة",
"render.cpu_threads_auto": "تلقائي",
"render.cpu_threads_custom": "مخصص",
"render.cpu_threads_help": "يستخدم الوضع التلقائي خيوط المعالجة المتاحة. ينفّذ خيط واحد العمل تسلسليًا. قد تحتاج الخيوط الإضافية إلى ذاكرة أكبر.",
"render.cpu_threads_single": "واحد",
"render.cpu_threads_web": "يستخدم التصيير في المتصفح خيطًا تعاونيًا واحدًا. يُحفظ اختيار خيوط المعالجة الخاص بسطح المكتب.",
"render.cpu_transparent_help": "يزيل PNG الشفاف الخلفية ويحافظ على تغطية الحواف. يجب أن تبقى عتامة الأجسام والأوجه عند 100%.",
"render.cpu_volume_enable_path": "تفعيل الإضاءة غير المباشرة",
"render.cpu_volume_enable_path_plan": "تتبع أشعة CPU · عمق المسار {depth} · {spp} عينة/بكسل · بذرة {seed} · ظلال متتبعة. يحتفظ بالغلاف الجوي وميزانية الصورة المحددين.",
@@ -3446,6 +3458,40 @@
"render.cpu_volume_method_incompatible": "لا يمكن نقل إعدادات الإضاءة أو أخذ العينات أو الغلاف الجوي هذه إلى هذه الطريقة. تبقى إعداداتك محفوظة.",
"render.cpu_volume_path_required": "فعّل الإضاءة غير المباشرة لاستخدام الغلاف الجوي على CPU.",
"render.cpu_warm_cool": "دافئ وبارد",
"render.denoise_class_background": "متجاوز: خلفية",
"render.denoise_class_coverage": "متجاوز: تغطية جزئية",
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
"render.denoise_class_lens": "متجاوز: عمق المجال",
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
"render.denoise_class_nested": "متجاوز: وسط متداخل",
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
"render.denoise_class_surface": "مؤهل: سطح",
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
"render.denoise_format_mask": "قناع PNG",
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
"render.denoise_optical_filter": "تصفية المرايا والزجاج",
"render.denoise_optical_filter_help": "تصفية الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة الضجيج من الأسطح المعتمة فقط.",
"render.denoise_passes": "مرات الترشيح",
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
"render.denoise_strength": "قوة إزالة الضوضاء",
"render.denoise_view_image": "الصورة المصيّرة",
"render.denoise_view_mask": "قناع إزالة الضوضاء",
"render.diag_bodies": "الأجسام المرئية",
"render.diag_job_details": "تفاصيل المهمة الملتقطة",
"render.diag_last_check": "آخر فحص مسبق",
@@ -4000,6 +4046,8 @@
"render.material_transient": "تجاوزات بصرية مؤقتة لعلامة تبويب المستند هذه. لا تُحفظ في التصميم ولا في إعداد التصيير ولا في مكتبة المواد.",
"render.material_v_up": "اتجاه V للنسيج يشير للأعلى في الصورة",
"render.material_weight": "وزن الطبقة (0–1)",
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
"render.method_cpu": "تتبع الأشعة على CPU",
"render.method_gpu": "تتبع المسارات على GPU",
"render.method_limit_raster": "التنقيط يستخدم معاينة الاستوديو المعتمة الثابتة. اختر طريقة تتبع أشعة صريحة لعناصر التحكم البصرية المدعومة.",
@@ -4128,6 +4176,8 @@
"render.quality_denoise_on": "تقليل التشويش: مفعّل",
"render.quality_draft": "مسودة",
"render.quality_high": "عالية",
"render.quality_legacy_samples": "القيمة المحفوظة: {samples} عينة لكل بكسل. تقتصر الاختيارات الجديدة على {maximum} عينة لكل بكسل.",
"render.quality_preset_limit": "يتجاوز هذا الإعداد المسبق حد الاختيار البالغ 16384 عينة لكل بكسل. اختر عدد عينات لا يتجاوز 16384.",
"render.quality_projection_help": "تغيّر هذه المراجعة الدقة وميزانية العينات وعمق المسار والبذرة وتقليل التشويش فقط. تُحفظ الإعدادات الأخرى؛ ولا تثبت الميزانية تقارب الصورة.",
"render.quality_review": "مراجعة ميزانية العيّنات",
"render.quality_summary": "{width} × {height} · {samples} عينة · العمق {depth} · البذرة {seed} · {denoise}",
@@ -4275,6 +4325,7 @@
"render.show_shots_label": "إظهار شريط اللقطات",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "مخصص…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4437,6 +4488,11 @@
"render.subsurface_enable": "مسار عشوائي للتشتت الداخلي",
"render.subsurface_help": "للعوازل المغلقة فقط. التشتت وامتصاص RGB معاملان منفصلان لكل mm. تتشتت المسارات داخل الهندسة الفعلية، ثم تنكسر أو تنعكس عند الحد. عطّل نفاذ الظل المستقيم وإزالة تشويش السطح والحجم الخارجي؛ واستخدم طبقة فرينل واحدة. ليس مظلل انتشار ولا شبكة كثافة ولا مادة مقيسة. الأوساط الكثيفة قد تحتاج مزيدًا من العيّنات وعمق المسار.",
"render.subsurface_rate": "معدل اصطدامات التشتت",
"render.supersampling": "أخذ عينات فائقة",
"render.supersampling_dimensions": "يُصيَّر بحجم {width} × {height} ويُصغَّر إلى {output_width} × {output_height}. يستخدم أربعة أضعاف عدد البكسلات.",
"render.supersampling_help": "يُصيَّر المشهد بضعف العرض والارتفاع، ثم يُصغَّر في فضاء الإضاءة الخطي. يبقى حجم الإخراج دون تغيير.",
"render.supersampling_off": "إيقاف",
"render.supersampling_x2": "2×",
"render.surface_import.author": "المؤلف",
"render.surface_import.busy": "جارٍ استيراد الصورة…",
"render.surface_import.complete": "اختر صورة وأدخل مؤلفها ومصدرها وترخيصها.",
@@ -4450,6 +4506,8 @@
"render.surface_import.saved_cpu": "احفظ الخرائط لاستخدامها في التصيير على CPU.",
"render.surface_import.source": "المصدر",
"render.surface_import.title": "استيراد صورة",
"render.texture_filtering": "ترشيح تفاصيل الخامات",
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً.",
"render.tone_aces_fitted": "ACES مُلاءَم",
"render.tone_reinhard": "راينهارد",
"render.transmission_scope": "يدعم زجاج GPU الأوساط المنفصلة المعتمدة. لا تتوفر الأوساط المتداخلة غير المقيدة ولا مقدّر نفاذ خالٍ من التشويش.",
+59 -1
View File
@@ -1319,6 +1319,7 @@
"cmd.timeline_stop": "Verlauf anhalten",
"cmd.timeline_suppress": "Merkmal unterdrücken",
"cmd.ui.assistant": "Assistenten ein-/ausblenden",
"cmd.ui.palette": "Befehle suchen",
"cmd.view.navigator": "Eigener Ansichtsnavigator",
"cmd.view.render_browser": "Render-Browser",
"cmd.view.render_dock": "Render-Panel verschieben",
@@ -2221,6 +2222,7 @@
"parts.dimension.width": "Breite",
"parts.family.bearing": "Rillenkugellager (Hüllform)",
"parts.family.button_head_cap_screw": "Linsenkopfschraube mit Innensechskant",
"parts.family.countersunk_socket_screw": "Senkschraube mit Innensechskant",
"parts.family.dowel_pin": "Zylinderstift",
"parts.family.hex_bolt": "Sechskantschraube",
"parts.family.hex_cap_screw_inch": "Sechskantschraube (Zoll)",
@@ -2591,6 +2593,8 @@
"prefs.remember_dialog_values.help": "Standardmäßig aus. Wenn aktiviert, werden nach erfolgreicher Ausführung eines Befehls seine sicheren Werte und Optionen beim nächsten Öffnen desselben Befehls wiederverwendet, bis SolidCraft geschlossen wird.",
"prefs.render_legacy_image_defaults": "Bisherige Render-Bildvorgaben verwenden",
"prefs.render_legacy_image_defaults_help": "Den bisherigen manuellen Ablauf beibehalten: neue Setups mit CPU-Raster, feste Abtastung beim CPU-Raytracing und manuell geöffnete Ergebnisse. Beim Wechsel zu Render die Ansicht neu einpassen, Aufnahmen ohne Wiederherstellung ihrer Kamera wechseln und die bisherige Bildausrichtung und Navigation verwenden. Automatische Live-Bildrückmeldungen deaktivieren. Gespeicherte Setups bleiben unverändert.",
"prefs.render_legacy_memory_admission": "Bisherige Render-Speicherprüfung verwenden",
"prefs.render_legacy_memory_admission_help": "Native Renderaufträge innerhalb der festen gemeinsamen und hostspezifischen Speichergrenzen zulassen, auch wenn der geschätzte Bedarf den verfügbaren RAM übersteigt. Gerätegrenzen und Prüfungen der Speicherzuweisung gelten weiterhin.",
"prefs.render_previous_geometry_budget": "Bisheriges Geometriebudget für die Render-Vorschau verwenden",
"prefs.render_previous_geometry_budget_help": "Stellt die bisherige feste Obergrenze für die Geometrie wieder her (512 MiB auf 64-Bit-Desktop-Systemen, andernfalls 128 MiB) anstelle des geräteabhängigen Budgets.",
"prefs.reports_saved": "{n} gespeichert",
@@ -2974,8 +2978,10 @@
"render.category_paint": "Lack",
"render.category_plastic": "Kunststoff",
"render.category_wood": "Holz",
"render.caustics_area_lights": "Kaustiken von Flächenlichtern",
"render.caustics_depth": "Kaustik-Pfadtiefe",
"render.caustics_enable": "Glaskaustiken durch Punkt- und Richtungslichter",
"render.caustics_environment": "Kaustiken aus der Umgebung",
"render.caustics_help": "Die Photonenzahl bestimmt Rauschen und Aufwand. Der Sammelradius tauscht scharfe Details gegen eine glattere Schätzung. CPU benötigt indirekte Beleuchtung.",
"render.caustics_path_required": "CPU-Kaustiken benötigen indirekte Beleuchtung.",
"render.caustics_photons": "Photonen",
@@ -3152,7 +3158,7 @@
"render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.",
"render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.",
"render.cpu_path_depth": "Pfadtiefe",
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–16). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–64). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_path_lighting": "Indirekte Beleuchtung",
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_point": "Punktlicht",
@@ -3192,6 +3198,12 @@
"render.cpu_subsurface_denoise_reason": "Streuung im Glas auf der CPU benötigt ausgeschaltete Rauschreduzierung. Die Einstellungen bleiben erhalten.",
"render.cpu_subsurface_help": "Streuung lenkt Licht im Inneren geschlossenen Glases um. Positive Raten benötigen indirekte Beleuchtung ohne Rauschreduzierung. Die Rate zählt Kollisionen pro mm; 0 fügt keine Streuung hinzu. Positive Richtungswerte bevorzugen vorwärts, negative rückwärts.",
"render.cpu_summary": "CPU-Raytracing · Reflexionen · HDR · Richtungs- und Punktlichter",
"render.cpu_threads": "CPU-Threads",
"render.cpu_threads_auto": "Auto",
"render.cpu_threads_custom": "Eigene",
"render.cpu_threads_help": "Auto verwendet die verfügbaren CPU-Threads. Ein Thread arbeitet seriell. Mehr Threads können mehr Speicher benötigen.",
"render.cpu_threads_single": "Einzeln",
"render.cpu_threads_web": "Im Browser wird ein kooperativer Thread verwendet. Die gespeicherte Desktop-Auswahl bleibt erhalten.",
"render.cpu_transparent_help": "Transparentes PNG entfernt den Hintergrund und erhält die Kantenabdeckung. Körper- und Flächendeckkraft müssen 100 % bleiben.",
"render.cpu_volume_enable_path": "Indirekte Beleuchtung einschalten",
"render.cpu_volume_enable_path_plan": "CPU-Raytracing · Pfadtiefe {depth} · {spp} Samples/Pixel · Seed {seed} · Raytracing-Schatten. Atmosphäre und Bildbudget bleiben erhalten.",
@@ -3200,6 +3212,40 @@
"render.cpu_volume_method_incompatible": "Diese Licht-, Sampling- oder Atmosphäreinstellungen lassen sich nicht auf diese Methode übertragen. Die Einstellungen bleiben erhalten.",
"render.cpu_volume_path_required": "Indirekte Beleuchtung einschalten, um CPU-Atmosphäre zu nutzen.",
"render.cpu_warm_cool": "Warm und kühl",
"render.denoise_class_background": "Übersprungen: Hintergrund",
"render.denoise_class_coverage": "Übersprungen: teilweise Abdeckung",
"render.denoise_class_degenerate": "Übersprungen: entarteter Pfad",
"render.denoise_class_depth": "Übersprungen: Pfadtiefengrenze",
"render.denoise_class_emitter": "Übersprungen: Lichtquelle",
"render.denoise_class_exit": "Übersprungen: unpassender Austritt",
"render.denoise_class_identity": "Übersprungen: Grenze der Führungskennungen",
"render.denoise_class_lens": "Übersprungen: Schärfentiefe",
"render.denoise_class_material": "Übersprungen: nicht unterstütztes Material",
"render.denoise_class_nested": "Übersprungen: verschachteltes Medium",
"render.denoise_class_optical": "Geeignet: Oberfläche durch Brechung",
"render.denoise_class_reflection": "Übersprungen: reflektierender Endpunkt",
"render.denoise_class_surface": "Geeignet: Oberfläche",
"render.denoise_class_tir": "Übersprungen: Totalreflexion",
"render.denoise_class_transmission": "Übersprungen: weitere Transmission",
"render.denoise_class_unavailable": "Übersprungen: keine Führungsdaten",
"render.denoise_class_volume": "Übersprungen: Volumenstreuung",
"render.denoise_diagnostics_counts": "Geeignet: {eligible} · Übersprungen: {skipped}",
"render.denoise_diagnostics_enable": "Entrauschungsdiagnose erfassen",
"render.denoise_diagnostics_grid": "Pixelzahlen bei interner Größe {width} × {height}.",
"render.denoise_diagnostics_help": "Erfasst geeignete und übersprungene Pixel auch ohne Entrauschung. Die Zählung nutzt die interne Rendergröße, die Maske die Ausgabegröße.",
"render.denoise_diagnostics_title": "Entrauschungsdiagnose",
"render.denoise_export_mask": "Maske als PNG exportieren",
"render.denoise_firefly": "Einzelne helle Pixel unterdrücken",
"render.denoise_format_mask": "Maske PNG",
"render.denoise_mask_copy_help": "Exportieren Sie die Entrauschungsmaske als PNG oder kehren Sie zum gerenderten Bild zurück, um es zu kopieren.",
"render.denoise_mask_unavailable": "Aktivieren Sie die Entrauschungsdiagnose und rendern Sie ein Bild, um seine Maske zu prüfen.",
"render.denoise_optical_filter": "Spiegel und Glas filtern",
"render.denoise_optical_filter_help": "Geeignetes reflektiertes und durchgelassenes Licht filtern. Ausschalten, um nur undurchsichtige Oberflächen zu entrauschen.",
"render.denoise_passes": "Filterdurchläufe",
"render.denoise_settings_help": "Mehr Durchläufe glätten einen größeren Bereich. Die Stärke regelt die Filterwirkung jedes Durchlaufs. Nicht unterstützte Lichtwege bleiben ungefiltert.",
"render.denoise_strength": "Entrauschungsstärke",
"render.denoise_view_image": "Gerendertes Bild",
"render.denoise_view_mask": "Entrauschungsmaske",
"render.diag_bodies": "Sichtbare Körper",
"render.diag_job_details": "Erfasste Auftragsdetails",
"render.diag_last_check": "Letzte Vorabprüfung",
@@ -3754,6 +3800,8 @@
"render.material_transient": "Vorübergehende optische Überschreibungen für diesen Dokument-Tab. Sie werden weder im Design noch im Render-Setup oder in der Materialbibliothek gespeichert.",
"render.material_v_up": "Textur-V zeigt im Bild nach oben",
"render.material_weight": "Klarlack-Gewicht (0–1)",
"render.metal_energy_compensation": "Reflexion rauer Metalle wiederherstellen",
"render.metal_energy_compensation_help": "Gleicht den Lichtverlust zwischen den Mikrofacetten rauer Metalle aus. Die CPU benötigt indirekte Beleuchtung.",
"render.method_cpu": "CPU-Raytracing",
"render.method_gpu": "GPU-Pathtracing",
"render.method_limit_raster": "Die Rasterung verwendet die feste undurchsichtige Studiovorschau. Wählen Sie ausdrücklich eine Raytracing-Methode für die unterstützten optischen Einstellungen.",
@@ -3882,6 +3930,8 @@
"render.quality_denoise_on": "Rauschreduzierung: Ein",
"render.quality_draft": "Entwurf",
"render.quality_high": "Hoch",
"render.quality_legacy_samples": "Gespeicherter Wert: {samples} spp. Neue Werte sind auf {maximum} spp begrenzt.",
"render.quality_preset_limit": "Diese Vorgabe überschreitet die Auswahlgrenze von 16384 spp. Wählen Sie höchstens 16384 Samples.",
"render.quality_projection_help": "Diese Prüfung ändert nur Auflösung, Abtastbudget, Pfadtiefe, Startwert und Rauschreduzierung. Andere Einstellungen bleiben erhalten; das Budget garantiert keine Konvergenz.",
"render.quality_review": "Sampling-Budget prüfen",
"render.quality_summary": "{width} × {height} · {samples} Samples · Tiefe {depth} · Startwert {seed} · {denoise}",
@@ -4029,6 +4079,7 @@
"render.show_shots_label": "Aufnahmenleiste einblenden",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Benutzerdefiniert…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4191,6 +4242,11 @@
"render.subsurface_enable": "Random Walk für innere Streuung",
"render.subsurface_help": "Nur für geschlossene Dielektrika. Streuung und RGB-Absorption sind getrennte Koeffizienten pro mm. Pfade streuen innerhalb der tatsächlichen Geometrie und werden dann an der Grenzfläche gebrochen oder reflektiert. Deaktivieren Sie die Transmission gerader Schattenstrahlen, das Entrauschen von Oberflächen und das äußere Volumen; verwenden Sie eine Fresnel-Schicht. Kein Diffusions-Shader, kein Dichtegitter und kein gemessenes Material. Dichte Medien können mehr Samples und Pfadtiefe benötigen.",
"render.subsurface_rate": "Streukollisionsrate",
"render.supersampling": "Supersampling",
"render.supersampling_dimensions": "Rendert {width} × {height} und verkleinert auf {output_width} × {output_height}. Verwendet die vierfache Pixelzahl.",
"render.supersampling_help": "Mit doppelter Breite und Höhe rendern und anschließend im linearen Farbraum verkleinern. Die Ausgabegröße bleibt unverändert.",
"render.supersampling_off": "Aus",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Autor",
"render.surface_import.busy": "Bild wird importiert…",
"render.surface_import.complete": "Wählen Sie ein Bild und geben Sie Autor, Quelle und Lizenz an.",
@@ -4204,6 +4260,8 @@
"render.surface_import.saved_cpu": "Speichern Sie Texturen für das CPU-Rendering.",
"render.surface_import.source": "Quelle",
"render.surface_import.title": "Bild importieren",
"render.texture_filtering": "Texturdetails filtern",
"render.texture_filtering_help": "Mip-Stufen und anisotrope Filterung verringern Flimmern verkleinerter Texturen. Benötigt zusätzlichen Speicher; ausgeschaltet bleibt die bisherige Filterung.",
"render.tone_aces_fitted": "ACES angepasst",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU-Glas unterstützt zertifizierte getrennte Medien. Unbeschränkt verschachtelte Medien und ein rauschfreier Transmissionsschätzer werden nicht bereitgestellt.",
+59 -1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "Stop History",
"cmd.timeline_suppress": "Suppress Feature",
"cmd.ui.assistant": "Toggle Assistant",
"cmd.ui.palette": "Search Commands",
"cmd.view.navigator": "Custom View Navigator",
"cmd.view.render_browser": "Render Browser",
"cmd.view.render_dock": "Move Render Dock",
@@ -2431,6 +2432,7 @@
"parts.dimension.width": "Width",
"parts.family.bearing": "Deep groove ball bearing (envelope)",
"parts.family.button_head_cap_screw": "Button head cap screw",
"parts.family.countersunk_socket_screw": "Countersunk socket screw",
"parts.family.dowel_pin": "Parallel pin (dowel)",
"parts.family.hex_bolt": "Hex head bolt / screw",
"parts.family.hex_cap_screw_inch": "Hex cap screw (inch)",
@@ -2826,6 +2828,8 @@
"prefs.remember_dialog_values.help": "Off by default. When on, after a command succeeds, its safe values and options are reused the next time you open the same command, until SolidCraft closes.",
"prefs.render_legacy_image_defaults": "Use previous Render image defaults",
"prefs.render_legacy_image_defaults_help": "Keep the previous manual workflow: new setups use CPU raster, CPU ray tracing keeps fixed sampling, and you open rendered results manually. Refit on Render entry, switch shots without restoring their cameras, and use the previous framing and navigation. Disable automatic live image feedback. Saved setups are unchanged.",
"prefs.render_legacy_memory_admission": "Use previous render memory checks",
"prefs.render_legacy_memory_admission_help": "Allow native renders within the fixed shared and host memory budgets even when their estimate exceeds available RAM. Device limits and allocation checks still apply.",
"prefs.render_previous_geometry_budget": "Use previous Render preview geometry budget",
"prefs.render_previous_geometry_budget_help": "Restore the previous fixed geometry limit (512 MiB on 64-bit desktop, 128 MiB elsewhere) instead of the device-based budget.",
"prefs.reports_saved": "{n} saved",
@@ -3220,8 +3224,10 @@
"render.category_paint": "Paint",
"render.category_plastic": "Plastic",
"render.category_wood": "Wood",
"render.caustics_area_lights": "Area light caustics",
"render.caustics_depth": "Caustic path depth",
"render.caustics_enable": "Glass caustics from point and directional lights",
"render.caustics_environment": "Environment caustics",
"render.caustics_help": "Photon count controls noise and work. Gather radius trades sharp detail for a smoother estimate. CPU requires Indirect lighting.",
"render.caustics_path_required": "CPU caustics require Indirect lighting.",
"render.caustics_photons": "Photons",
@@ -3398,7 +3404,7 @@
"render.cpu_opaque_required": "CPU ray tracing needs an opaque background. Clear Transparent background to continue.",
"render.cpu_options_pending": "This CPU panel cannot edit emission, surface effects, glass or anisotropy yet. Clear those API options before using it.",
"render.cpu_path_depth": "Path depth",
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–16). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–64). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_path_lighting": "Indirect lighting",
"render.cpu_path_optics_reason": "Your settings are kept. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_point": "Point light",
@@ -3438,6 +3444,12 @@
"render.cpu_subsurface_denoise_reason": "CPU subsurface scattering needs Denoise off. Your settings are kept.",
"render.cpu_subsurface_help": "Scattering redirects light inside closed Glass. Positive rates need Indirect lighting with Denoise off. The rate is collisions per mm; 0 adds no scattering. Positive phase bias favors forward directions; negative favors backward.",
"render.cpu_summary": "CPU ray tracing · reflections · HDR · directional and point lights",
"render.cpu_threads": "CPU workers",
"render.cpu_threads_auto": "Auto",
"render.cpu_threads_custom": "Custom",
"render.cpu_threads_help": "Auto uses available CPU workers. One worker runs serially. More workers can need more memory.",
"render.cpu_threads_single": "Single",
"render.cpu_threads_web": "Browser rendering uses one cooperative worker. The saved desktop worker choice is preserved.",
"render.cpu_transparent_help": "Transparent PNG removes the background and keeps edge coverage. Body and face opacity must stay at 100%.",
"render.cpu_volume_enable_path": "Enable Indirect lighting",
"render.cpu_volume_enable_path_plan": "CPU ray tracing · path depth {depth} · {spp} samples/pixel · seed {seed} · traced shadows. Keeps the authored atmosphere and image budget.",
@@ -3446,6 +3458,40 @@
"render.cpu_volume_method_incompatible": "These light, sampling or atmosphere settings cannot be transferred to this method. Your settings are kept.",
"render.cpu_volume_path_required": "Enable Indirect lighting to use CPU Atmosphere.",
"render.cpu_warm_cool": "Warm and cool",
"render.denoise_class_background": "Skipped: background",
"render.denoise_class_coverage": "Skipped: partial coverage",
"render.denoise_class_degenerate": "Skipped: degenerate path",
"render.denoise_class_depth": "Skipped: path depth limit",
"render.denoise_class_emitter": "Skipped: light emitter",
"render.denoise_class_exit": "Skipped: incompatible exit",
"render.denoise_class_identity": "Skipped: guide identity limit",
"render.denoise_class_lens": "Skipped: depth of field",
"render.denoise_class_material": "Skipped: unsupported material",
"render.denoise_class_nested": "Skipped: nested medium",
"render.denoise_class_optical": "Eligible: refracted surface",
"render.denoise_class_reflection": "Skipped: reflective endpoint",
"render.denoise_class_surface": "Eligible: surface",
"render.denoise_class_tir": "Skipped: total internal reflection",
"render.denoise_class_transmission": "Skipped: further transmission",
"render.denoise_class_unavailable": "Skipped: no guide",
"render.denoise_class_volume": "Skipped: volume scattering",
"render.denoise_diagnostics_counts": "Eligible: {eligible} · Skipped: {skipped}",
"render.denoise_diagnostics_enable": "Collect denoising diagnostics",
"render.denoise_diagnostics_grid": "Pixel counts at internal size {width} × {height}.",
"render.denoise_diagnostics_help": "Collects eligible and skipped pixel counts even when denoising is off. Counts use the internal render size; the mask uses the output size.",
"render.denoise_diagnostics_title": "Denoising diagnostics",
"render.denoise_export_mask": "Export mask PNG",
"render.denoise_firefly": "Suppress isolated bright pixels",
"render.denoise_format_mask": "Mask PNG",
"render.denoise_mask_copy_help": "Export the denoising mask as PNG, or return to the rendered image to copy it.",
"render.denoise_mask_unavailable": "Enable denoising diagnostics and render an image to inspect its mask.",
"render.denoise_optical_filter": "Filter mirror and glass",
"render.denoise_optical_filter_help": "Filter eligible reflected and transmitted light. Turn off to denoise opaque surfaces only.",
"render.denoise_passes": "Filter passes",
"render.denoise_settings_help": "More passes smooth a wider area. Strength controls how much filtering each pass applies. Unsupported optical paths remain unfiltered.",
"render.denoise_strength": "Denoising strength",
"render.denoise_view_image": "Rendered image",
"render.denoise_view_mask": "Denoising mask",
"render.diag_bodies": "Visible bodies",
"render.diag_job_details": "Captured job details",
"render.diag_last_check": "Last preflight",
@@ -4000,6 +4046,8 @@
"render.material_transient": "Temporary optical overrides for this document tab. They are not saved in the design, render setup or material library.",
"render.material_v_up": "Texture V points upward in the image",
"render.material_weight": "Coat weight (0–1)",
"render.metal_energy_compensation": "Recover rough metal reflectance",
"render.metal_energy_compensation_help": "Compensate for light lost between rough metal microfacets. CPU rendering needs Indirect lighting.",
"render.method_cpu": "CPU ray tracing",
"render.method_gpu": "GPU path tracing",
"render.method_limit_raster": "Raster uses the fixed opaque studio preview. Select an explicit raytracing method for supported optical controls.",
@@ -4128,6 +4176,8 @@
"render.quality_denoise_on": "Noise reduction: On",
"render.quality_draft": "Draft",
"render.quality_high": "High",
"render.quality_legacy_samples": "Saved value: {samples} spp. New selections are limited to {maximum} spp.",
"render.quality_preset_limit": "This preset exceeds the 16384 spp selection limit. Choose a sample count up to 16384.",
"render.quality_projection_help": "This review changes only resolution, sample budget, path depth, seed and noise reduction. Other settings are kept; the budget does not certify convergence.",
"render.quality_review": "Review sampling budget",
"render.quality_summary": "{width} × {height} · {samples} samples · depth {depth} · seed {seed} · {denoise}",
@@ -4275,6 +4325,7 @@
"render.show_shots_label": "Show Shots strip",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Custom…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4437,6 +4488,11 @@
"render.subsurface_enable": "Interior scattering random walk",
"render.subsurface_help": "Closed dielectric only. Scattering and RGB absorption are separate per-mm coefficients. Paths scatter inside actual geometry, then refract or reflect at the boundary. Disable straight shadow transmission, surface denoising and exterior volume; use one Fresnel stratum. Not a diffusion shader, density grid or measured material. Dense media may need more samples and path depth.",
"render.subsurface_rate": "Scattering collision rate",
"render.supersampling": "Supersampling",
"render.supersampling_dimensions": "Renders {width} × {height} and reduces to {output_width} × {output_height}. Uses four times the pixels.",
"render.supersampling_help": "Render at twice the width and height, then reduce in linear light. The output size stays unchanged.",
"render.supersampling_off": "Off",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Author",
"render.surface_import.busy": "Importing image…",
"render.surface_import.complete": "Choose an image and enter its author, source and license.",
@@ -4450,6 +4506,8 @@
"render.surface_import.saved_cpu": "Save maps to use them with CPU rendering.",
"render.surface_import.source": "Source",
"render.surface_import.title": "Import image",
"render.texture_filtering": "Filter texture detail",
"render.texture_filtering_help": "Use mip levels and anisotropic filtering to reduce texture aliasing under minification. Uses more memory; off retains the previous filtering.",
"render.tone_aces_fitted": "ACES fitted",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU glass supports certified disjoint media. Unrestricted nested media and a noise-free transmission estimator are not provided.",
+59 -1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "עצירת ציר הזמן",
"cmd.timeline_suppress": "השבתת פיצ'ר",
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
"cmd.ui.palette": "חיפוש פקודות",
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
"cmd.view.render_browser": "דפדפן רינדור",
"cmd.view.render_dock": "הזז את עגינת הרינדור",
@@ -2431,6 +2432,7 @@
"parts.dimension.width": "רוחב",
"parts.family.bearing": "מסב כדורי חריץ עמוק (מעטפת)",
"parts.family.button_head_cap_screw": "בורג ראש כפתור עם שקע משושה",
"parts.family.countersunk_socket_screw": "בורג ראש שקוע עם שקע משושה",
"parts.family.dowel_pin": "פין גלילי",
"parts.family.hex_bolt": "בורג ראש משושה",
"parts.family.hex_cap_screw_inch": "בורג ראש משושה (אינץ')",
@@ -2826,6 +2828,8 @@
"prefs.remember_dialog_values.help": "כבוי כברירת מחדל. כשהוא פועל, לאחר הצלחת פקודה, הערכים והאפשרויות הבטוחים שלה ישמשו שוב בפעם הבאה שתפתח את אותה פקודה, עד לסגירת SolidCraft.",
"prefs.render_legacy_image_defaults": "שימוש בברירות המחדל הקודמות לתמונות הדמיה",
"prefs.render_legacy_image_defaults_help": "שמירה על תהליך העבודה הידני הקודם: הגדרות חדשות ברסטר CPU, דגימה קבועה בעקיבת קרניים ב־CPU ופתיחת התוצאות ידנית. התאמת התצוגה מחדש בכניסה להדמיה, מעבר בין צילומים בלי לשחזר את המצלמות שלהם ושימוש במסגור ובניווט הקודמים. עדכון אוטומטי של התמונה החיה מושבת. הגדרות שמורות אינן משתנות.",
"prefs.render_legacy_memory_admission": "שימוש בבדיקות הזיכרון הקודמות לרינדור",
"prefs.render_legacy_memory_admission_help": "מאפשר רינדור מקומי במסגרת מגבלות הזיכרון הקבועות המשותפות ושל המארח, גם כשההערכה חורגת מהזיכרון הזמין. מגבלות המכשיר ובדיקות הקצאת הזיכרון עדיין חלות.",
"prefs.render_previous_geometry_budget": "שימוש בתקציב הגאומטריה הקודם לתצוגה מקדימה של רינדור",
"prefs.render_previous_geometry_budget_help": "שחזור מגבלת הגאומטריה הקבועה הקודמת (512 MiB במחשב שולחני של 64 סיביות, 128 MiB בפלטפורמות אחרות) במקום התקציב המבוסס על ההתקן.",
"prefs.reports_saved": "{n} שמורים",
@@ -3220,8 +3224,10 @@
"render.category_paint": "צבע",
"render.category_plastic": "פלסטיק",
"render.category_wood": "עץ",
"render.caustics_area_lights": "קאוסטיקה מתאורת שטח",
"render.caustics_depth": "עומק מסלול ריכוז האור",
"render.caustics_enable": "ריכוז אור דרך זכוכית ממקורות נקודתיים וכיווניים",
"render.caustics_environment": "קאוסטיקה מתאורת הסביבה",
"render.caustics_help": "מספר הפוטונים שולט ברעש ובעומס החישוב. רדיוס האיסוף מאזן בין פרטים חדים לאומדן חלק יותר. CPU דורש תאורה עקיפה.",
"render.caustics_path_required": "ריכוז אור ב-CPU דורש תאורה עקיפה.",
"render.caustics_photons": "מספר פוטונים",
@@ -3398,7 +3404,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–64). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
"render.cpu_path_lighting": "תאורה עקיפה",
"render.cpu_path_optics_reason": "ההגדרות שלך נשמרות. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
"render.cpu_point": "אור נקודתי",
@@ -3438,6 +3444,12 @@
"render.cpu_subsurface_denoise_reason": "פיזור בתוך זכוכית ב־CPU דורש הסרת רעש כבויה. ההגדרות נשמרות.",
"render.cpu_subsurface_help": "פיזור משנה את כיוון האור בתוך זכוכית סגורה. קצב חיובי דורש תאורה עקיפה והסרת רעש כבויה. הקצב הוא מספר התנגשויות למ״מ; 0 אינו מוסיף פיזור. הטיית כיוון חיובית מעדיפה קדימה, ושלילית אחורה.",
"render.cpu_summary": "מעקב קרניים ב־CPU · החזרות · HDR · אורות כיווניים ונקודתיים",
"render.cpu_threads": "תהליכוני CPU",
"render.cpu_threads_auto": "אוטומטי",
"render.cpu_threads_custom": "מותאם",
"render.cpu_threads_help": "המצב האוטומטי משתמש בתהליכוני ה־CPU הזמינים. תהליכון יחיד פועל באופן סדרתי. תהליכונים נוספים עשויים לדרוש יותר זיכרון.",
"render.cpu_threads_single": "יחיד",
"render.cpu_threads_web": "רינדור בדפדפן משתמש בתהליכון שיתופי אחד. בחירת התהליכונים השמורה למחשב נשמרת.",
"render.cpu_transparent_help": "PNG שקוף מסיר את הרקע ושומר על כיסוי הקצוות. אטימות הגופים והפאות חייבת להישאר 100%.",
"render.cpu_volume_enable_path": "הפעלת תאורה עקיפה",
"render.cpu_volume_enable_path_plan": "מעקב קרניים ב־CPU · עומק מסלול {depth} · {spp} דגימות לפיקסל · זרע {seed} · צללים במעקב קרניים. האטמוספרה ותקציב התמונה שנקבעו נשמרים.",
@@ -3446,6 +3458,40 @@
"render.cpu_volume_method_incompatible": "לא ניתן להעביר הגדרות תאורה, דגימה או אטמוספרה אלה לשיטה זו. ההגדרות שלך נשמרות.",
"render.cpu_volume_path_required": "יש להפעיל תאורה עקיפה כדי להשתמש באטמוספרה ב־CPU.",
"render.cpu_warm_cool": "חם וקר",
"render.denoise_class_background": "דולג: רקע",
"render.denoise_class_coverage": "דולג: כיסוי חלקי",
"render.denoise_class_degenerate": "דולג: מסלול מנוון",
"render.denoise_class_depth": "דולג: מגבלת עומק מסלול",
"render.denoise_class_emitter": "דולג: מקור אור",
"render.denoise_class_exit": "דולג: יציאה לא תואמת",
"render.denoise_class_identity": "דולג: מגבלת מזהי הנחיה",
"render.denoise_class_lens": "דולג: עומק שדה",
"render.denoise_class_material": "דולג: חומר לא נתמך",
"render.denoise_class_nested": "דולג: תווך מקונן",
"render.denoise_class_optical": "מתאים: משטח דרך שבירה",
"render.denoise_class_reflection": "דולג: נקודת סיום מחזירה",
"render.denoise_class_surface": "מתאים: משטח",
"render.denoise_class_tir": "דולג: החזרה פנימית מלאה",
"render.denoise_class_transmission": "דולג: העברה נוספת",
"render.denoise_class_unavailable": "דולג: אין נתוני הנחיה",
"render.denoise_class_volume": "דולג: פיזור נפחי",
"render.denoise_diagnostics_counts": "מתאימים: {eligible} · דולגו: {skipped}",
"render.denoise_diagnostics_enable": "איסוף נתוני אבחון להפחתת רעש",
"render.denoise_diagnostics_grid": "ספירת פיקסלים בגודל הפנימי {width} × {height}.",
"render.denoise_diagnostics_help": "נאספים מספרי הפיקסלים המתאימים ואלה שדולגו, גם כשהפחתת הרעש כבויה. הספירה לפי גודל הרינדור הפנימי; המסכה לפי גודל הפלט.",
"render.denoise_diagnostics_title": "אבחון הפחתת רעש",
"render.denoise_export_mask": "ייצוא המסכה כ־PNG",
"render.denoise_firefly": "דיכוי פיקסלים בהירים מבודדים",
"render.denoise_format_mask": "מסכה PNG",
"render.denoise_mask_copy_help": "אפשר לייצא את מסכת הפחתת הרעש כ־PNG, או לחזור לתמונה המרונדרת כדי להעתיק אותה.",
"render.denoise_mask_unavailable": "יש להפעיל אבחון הפחתת רעש ולרנדר תמונה כדי לבדוק את המסכה שלה.",
"render.denoise_optical_filter": "סינון מראות וזכוכית",
"render.denoise_optical_filter_help": "סינון אור מוחזר ומועבר שמתאים לסינון. כיבוי האפשרות מגביל את הסרת הרעש למשטחים אטומים.",
"render.denoise_passes": "מעברי סינון",
"render.denoise_settings_help": "מעברים נוספים מחליקים אזור רחב יותר. העוצמה קובעת כמה סינון מופעל בכל מעבר. מסלולים אופטיים שאינם נתמכים נשארים ללא סינון.",
"render.denoise_strength": "עוצמת הפחתת רעש",
"render.denoise_view_image": "תמונה מרונדרת",
"render.denoise_view_mask": "מסכת הפחתת רעש",
"render.diag_bodies": "גופים גלויים",
"render.diag_job_details": "פרטי המשימה שנלכדו",
"render.diag_last_check": "בדיקה מקדימה אחרונה",
@@ -4000,6 +4046,8 @@
"render.material_transient": "דריסות אופטיות זמניות ללשונית המסמך הזאת. הן אינן נשמרות בתכן, בהגדרת ההדמיה או בספריית החומרים.",
"render.material_v_up": "כיוון V של הטקסטורה מצביע מעלה בתמונה",
"render.material_weight": "משקל הציפוי (0–1)",
"render.metal_energy_compensation": "שחזור ההחזר של מתכת מחוספסת",
"render.metal_energy_compensation_help": "מפצה על האור שאובד בין הפאות המיקרוסקופיות של מתכת מחוספסת. עיבוד במעבד דורש תאורה עקיפה.",
"render.method_cpu": "מעקב קרניים ב־CPU",
"render.method_gpu": "מעקב מסלולים ב־GPU",
"render.method_limit_raster": "רסטר משתמש בתצוגה המקדימה הקבועה של הסטודיו האטום. לפקדים האופטיים הנתמכים יש לבחור שיטת מעקב קרניים מפורשת.",
@@ -4128,6 +4176,8 @@
"render.quality_denoise_on": "הפחתת רעש: פעילה",
"render.quality_draft": "טיוטה",
"render.quality_high": "גבוהה",
"render.quality_legacy_samples": "ערך שמור: {samples} דגימות לפיקסל. בחירות חדשות מוגבלות ל־{maximum} דגימות לפיקסל.",
"render.quality_preset_limit": "הגדרה קבועה זו חורגת ממגבלת הבחירה של 16384 דגימות לפיקסל. יש לבחור עד 16384 דגימות.",
"render.quality_projection_help": "בדיקה זו משנה רק רזולוציה, תקציב דגימות, עומק נתיב, זרע והפחתת רעש. ההגדרות האחרות נשמרות; התקציב אינו מעיד על התכנסות התמונה.",
"render.quality_review": "סקירת תקציב הדגימה",
"render.quality_summary": "{width} × {height} · {samples} דגימות · עומק {depth} · זרע {seed} · {denoise}",
@@ -4275,6 +4325,7 @@
"render.show_shots_label": "הצג את סרגל הצילומים",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "מותאם…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4437,6 +4488,11 @@
"render.subsurface_enable": "הילוך אקראי לפיזור פנימי",
"render.subsurface_help": "לדיאלקטרי סגור בלבד. פיזור ובליעת RGB הם מקדמים נפרדים לכל mm. המסלולים מתפזרים בתוך הגאומטריה בפועל, ואז נשברים או מוחזרים בגבול. יש לבטל העברת צל ישרה, הסרת רעש משטחית ונפח חיצוני; ולהשתמש בשכבת פרנל אחת. אינו שיידר דיפוזיה, אינו רשת צפיפות ואינו חומר מדוד. תווך צפוף עשוי לדרוש יותר דגימות ועומק מסלול.",
"render.subsurface_rate": "קצב התנגשויות הפיזור",
"render.supersampling": "דגימת יתר",
"render.supersampling_dimensions": "רינדור בגודל {width} × {height} והקטנה ל־{output_width} × {output_height}. נעשה שימוש בפי ארבעה פיקסלים.",
"render.supersampling_help": "רינדור ברוחב ובגובה כפולים, ואז הקטנה במרחב אור ליניארי. גודל הפלט נשאר ללא שינוי.",
"render.supersampling_off": "כבוי",
"render.supersampling_x2": "2×",
"render.surface_import.author": "יוצר",
"render.surface_import.busy": "מתבצע ייבוא תמונה…",
"render.surface_import.complete": "בחר תמונה והזן את היוצר, המקור והרישיון שלה.",
@@ -4450,6 +4506,8 @@
"render.surface_import.saved_cpu": "יש לשמור את המפות לשימוש בהדמיה ב־CPU.",
"render.surface_import.source": "מקור",
"render.surface_import.title": "ייבוא תמונה",
"render.texture_filtering": "סינון פרטי מרקם",
"render.texture_filtering_help": "שימוש ברמות פירוט ובסינון אנאיזוטרופי מפחית שינון במרקמים מוקטנים. דורש יותר זיכרון; כשהאפשרות כבויה נשמר הסינון הקודם.",
"render.tone_aces_fitted": "ACES מותאם",
"render.tone_reinhard": "ריינהרד",
"render.transmission_scope": "זכוכית GPU תומכת בתווכים נפרדים מאושרים. תווכים מקוננים ללא הגבלה ומעריך העברה ללא רעש אינם מסופקים.",
+59 -1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "履歴を停止",
"cmd.timeline_suppress": "フィーチャーを抑制",
"cmd.ui.assistant": "アシスタントの表示切り替え",
"cmd.ui.palette": "コマンドを検索",
"cmd.view.navigator": "カスタム ビューナビゲーター",
"cmd.view.render_browser": "Renderブラウザー",
"cmd.view.render_dock": "レンダードックを移動",
@@ -2431,6 +2432,7 @@
"parts.dimension.width": "幅",
"parts.family.bearing": "深溝玉軸受(外形)",
"parts.family.button_head_cap_screw": "ボタンヘッド六角穴付きボルト",
"parts.family.countersunk_socket_screw": "六角穴付き皿ボルト",
"parts.family.dowel_pin": "平行ピン",
"parts.family.hex_bolt": "六角ボルト",
"parts.family.hex_cap_screw_inch": "六角ボルト(インチ)",
@@ -2826,6 +2828,8 @@
"prefs.remember_dialog_values.help": "既定ではオフです。オンにすると、コマンドが成功した後、SolidCraft を閉じるまで、同じコマンドを次に開いたときに安全な値とオプションを再利用します。",
"prefs.render_legacy_image_defaults": "以前のレンダー画像の既定値を使用",
"prefs.render_legacy_image_defaults_help": "以前の手動の画像ワークフローを使用します。新しいセットアップはCPUラスタ、CPUレイトレーシングは固定サンプリングとし、結果は手動で開きます。レンダーに入るたびにビューをフィットし、ショットを切り替えても保存カメラを復元せず、以前のフレーミングとナビゲーションを使用します。自動のライブ画像更新は無効にします。保存済みのセットアップは変更しません。",
"prefs.render_legacy_memory_admission": "以前のレンダリング用メモリチェックを使用",
"prefs.render_legacy_memory_admission_help": "推定使用量が使用可能なRAMを超えていても、共有およびホストの固定メモリ上限内でネイティブレンダリングを許可します。デバイスの制限とメモリ割り当てのチェックは引き続き適用されます。",
"prefs.render_previous_geometry_budget": "レンダープレビューで以前のジオメトリ予算を使用",
"prefs.render_previous_geometry_budget_help": "デバイスに基づく予算の代わりに、以前の固定ジオメトリ上限(64ビットデスクトップでは512 MiB、その他では128 MiB)に戻します。",
"prefs.reports_saved": "{n} 件保存済み",
@@ -3220,8 +3224,10 @@
"render.category_paint": "塗装",
"render.category_plastic": "プラスチック",
"render.category_wood": "木材",
"render.caustics_area_lights": "面光源のコースティクス",
"render.caustics_depth": "集光経路の深さ",
"render.caustics_enable": "点光源・平行光源によるガラスの集光",
"render.caustics_environment": "環境光のコースティクス",
"render.caustics_help": "光子数はノイズと計算量を調整します。収集半径を広げると細部より滑らかさを優先します。CPUでは間接照明が必要です。",
"render.caustics_path_required": "CPUの集光には間接照明が必要です。",
"render.caustics_photons": "光子数",
@@ -3398,7 +3404,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~64)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
"render.cpu_path_lighting": "間接照明",
"render.cpu_path_optics_reason": "設定は保持されます。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
"render.cpu_point": "点光源",
@@ -3438,6 +3444,12 @@
"render.cpu_subsurface_denoise_reason": "CPUの内部散乱ではノイズ除去をオフにしてください。設定値は保持されます。",
"render.cpu_subsurface_help": "散乱は閉じたガラスの内部で光の方向を変えます。正の値には間接照明を使い、ノイズ除去をオフにします。率は1mmあたりの衝突回数です。0では散乱を加えません。方向の偏りが正なら前方、負なら後方を優先します。",
"render.cpu_summary": "CPUレイトレーシング・反射・HDR・平行光と点光源",
"render.cpu_threads": "CPU ワーカー",
"render.cpu_threads_auto": "自動",
"render.cpu_threads_custom": "カスタム",
"render.cpu_threads_help": "自動では利用可能な CPU ワーカーを使用します。1 ワーカーでは順次実行します。ワーカー数を増やすとメモリ使用量が増える場合があります。",
"render.cpu_threads_single": "1 ワーカー",
"render.cpu_threads_web": "ブラウザーでのレンダリングは単一の協調ワーカーを使用します。保存されたデスクトップ用ワーカー設定は保持されます。",
"render.cpu_transparent_help": "透明PNGは背景を除き、輪郭の被覆率を保持します。ボディと面の不透明度は100%にしてください。",
"render.cpu_volume_enable_path": "間接照明を有効にする",
"render.cpu_volume_enable_path_plan": "CPUレイトレーシング · パス深度{depth} · {spp}サンプル/ピクセル · シード{seed} · レイトレースシャドウ。大気と画像予算の設定を保持します。",
@@ -3446,6 +3458,40 @@
"render.cpu_volume_method_incompatible": "この照明、サンプリング、大気の設定はこの方式に引き継げません。設定は保持されます。",
"render.cpu_volume_path_required": "CPUの大気を使うには間接照明を有効にしてください。",
"render.cpu_warm_cool": "暖色と寒色",
"render.denoise_class_background": "対象外: 背景",
"render.denoise_class_coverage": "対象外: 部分的なカバレッジ",
"render.denoise_class_degenerate": "対象外: 退化した経路",
"render.denoise_class_depth": "対象外: 経路深度の上限",
"render.denoise_class_emitter": "対象外: 発光体",
"render.denoise_class_exit": "対象外: 不整合な出射",
"render.denoise_class_identity": "対象外: ガイド識別子の上限",
"render.denoise_class_lens": "対象外: 被写界深度",
"render.denoise_class_material": "対象外: 未対応のマテリアル",
"render.denoise_class_nested": "対象外: 入れ子の媒質",
"render.denoise_class_optical": "対象: 屈折越しのサーフェス",
"render.denoise_class_reflection": "対象外: 反射する終点",
"render.denoise_class_surface": "対象: サーフェス",
"render.denoise_class_tir": "対象外: 全反射",
"render.denoise_class_transmission": "対象外: 追加の透過",
"render.denoise_class_unavailable": "対象外: ガイドなし",
"render.denoise_class_volume": "対象外: ボリューム散乱",
"render.denoise_diagnostics_counts": "対象: {eligible} · 対象外: {skipped}",
"render.denoise_diagnostics_enable": "ノイズ除去の診断情報を収集",
"render.denoise_diagnostics_grid": "内部サイズ {width} × {height} でのピクセル数。",
"render.denoise_diagnostics_help": "ノイズ除去がオフでも、対象と対象外のピクセル数を収集します。集計は内部レンダリングサイズ、マスクは出力サイズに対応します。",
"render.denoise_diagnostics_title": "ノイズ除去の診断",
"render.denoise_export_mask": "マスク PNG を書き出す",
"render.denoise_firefly": "孤立した高輝度ピクセルを抑制",
"render.denoise_format_mask": "マスク PNG",
"render.denoise_mask_copy_help": "ノイズ除去マスクを PNG で書き出すか、レンダリング画像に戻ってコピーしてください。",
"render.denoise_mask_unavailable": "ノイズ除去の診断を有効にして画像をレンダリングすると、マスクを確認できます。",
"render.denoise_optical_filter": "鏡とガラスをフィルター処理",
"render.denoise_optical_filter_help": "対象となる反射光と透過光にフィルターを適用します。オフにすると、不透明な表面のみのノイズを除去します。",
"render.denoise_passes": "フィルターパス数",
"render.denoise_settings_help": "パス数を増やすと、より広い範囲が平滑化されます。強度は各パスでのフィルター適用量を調整します。未対応の光路にはフィルターを適用しません。",
"render.denoise_strength": "ノイズ除去の強度",
"render.denoise_view_image": "レンダリング画像",
"render.denoise_view_mask": "ノイズ除去マスク",
"render.diag_bodies": "表示ボディ数",
"render.diag_job_details": "記録されたジョブの詳細",
"render.diag_last_check": "前回の事前確認",
@@ -4000,6 +4046,8 @@
"render.material_transient": "この文書タブの一時的な光学設定です。設計、レンダー設定、マテリアルライブラリには保存されません。",
"render.material_v_up": "テクスチャV軸は画像の上向き",
"render.material_weight": "コートの重み(0~1)",
"render.metal_energy_compensation": "粗い金属の反射を補正",
"render.metal_energy_compensation_help": "粗い金属の微小面間で失われる光を補います。CPU レンダリングには間接照明が必要です。",
"render.method_cpu": "CPU レイトレーシング",
"render.method_gpu": "GPU パストレーシング",
"render.method_limit_raster": "ラスターは固定の不透明スタジオプレビューを使用します。対応する光学設定には明示的なレイトレーシング方式を選択してください。",
@@ -4128,6 +4176,8 @@
"render.quality_denoise_on": "ノイズ低減:オン",
"render.quality_draft": "ドラフト",
"render.quality_high": "高品質",
"render.quality_legacy_samples": "保存値: {samples} spp。新しい選択値の上限は {maximum} spp です。",
"render.quality_preset_limit": "このプリセットは選択上限の 16384 spp を超えています。16384 以下のサンプル数を選択してください。",
"render.quality_projection_help": "この確認で変更するのは、解像度、サンプル予算、パス深度、シード、ノイズ低減だけです。他の設定は維持され、収束は保証されません。",
"render.quality_review": "サンプリング予算を確認",
"render.quality_summary": "{width} × {height} · {samples} サンプル · 深度 {depth} · シード {seed} · {denoise}",
@@ -4275,6 +4325,7 @@
"render.show_shots_label": "ショットストリップを表示",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "カスタム…",
"render.size_full_hd": "フルHD",
"render.size_hd": "HD",
@@ -4437,6 +4488,11 @@
"render.subsurface_enable": "内部散乱ランダムウォーク",
"render.subsurface_help": "閉じた誘電体専用。散乱とRGB吸収は別々の毎mm係数です。実際の形状内で散乱し、境界で屈折・反射します。直線透過影、表面ノイズ除去、外部ボリュームを無効にし、フレネル層数を1にします。拡散シェーダー、密度グリッド、実測物性ではありません。高密度ではサンプル数と深度が必要です。",
"render.subsurface_rate": "散乱衝突率",
"render.supersampling": "スーパーサンプリング",
"render.supersampling_dimensions": "{width} × {height} でレンダリングし、{output_width} × {output_height} に縮小します。ピクセル数は 4 倍になります。",
"render.supersampling_help": "幅と高さをそれぞれ 2 倍にしてレンダリングし、リニアライトで縮小します。出力サイズは変わりません。",
"render.supersampling_off": "オフ",
"render.supersampling_x2": "2×",
"render.surface_import.author": "作者",
"render.surface_import.busy": "画像をインポート中…",
"render.surface_import.complete": "画像を選び、作者、出典、ライセンスを入力してください。",
@@ -4450,6 +4506,8 @@
"render.surface_import.saved_cpu": "CPUレンダリングで使うにはマップを保存してください。",
"render.surface_import.source": "出典",
"render.surface_import.title": "画像をインポート",
"render.texture_filtering": "テクスチャの細部をフィルタリング",
"render.texture_filtering_help": "ミップレベルと異方性フィルタリングで縮小時のちらつきを抑えます。メモリ使用量が増えます。オフでは従来のフィルタリングを維持します。",
"render.tone_aces_fitted": "ACES近似",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPUガラスは検証された非交差媒体に対応します。無制限の入れ子媒体やノイズのない透過推定器は提供しません。",
+58 -1
View File
@@ -1252,6 +1252,7 @@
"cmd.timeline_stop": "기록 재생 중지",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.ui.assistant": "도우미 표시 전환",
"cmd.ui.palette": "명령 검색",
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
"cmd.view.render_browser": "렌더 브라우저",
"cmd.view.render_dock": "렌더 도크 이동",
@@ -2129,6 +2130,8 @@
"prefs.remember_dialog_values.help": "기본값은 꺼짐입니다. 켜면 명령이 성공한 후 SolidCraft를 닫을 때까지 같은 명령을 다음에 열 때 안전한 값과 옵션을 다시 사용합니다.",
"prefs.render_legacy_image_defaults": "이전 렌더 이미지 기본값 사용",
"prefs.render_legacy_image_defaults_help": "이전의 수동 이미지 작업 방식을 유지합니다. 새 설정은 CPU 래스터를 사용하고 CPU 광선 추적은 고정 샘플링을 사용하며 결과는 직접 엽니다. 렌더에 들어갈 때 뷰를 다시 맞추고, 샷을 전환해도 저장된 카메라를 복원하지 않으며, 이전의 프레이밍과 탐색 방식을 사용합니다. 자동 실시간 이미지 피드백을 끕니다. 저장된 설정은 변경하지 않습니다.",
"prefs.render_legacy_memory_admission": "이전 렌더링 메모리 검사 사용",
"prefs.render_legacy_memory_admission_help": "예상 사용량이 사용 가능한 RAM을 초과해도 고정된 공유 및 호스트 메모리 한도 내에서 네이티브 렌더링을 허용합니다. 장치 제한과 메모리 할당 검사는 계속 적용됩니다.",
"prefs.reports_saved": "{n}개 저장됨",
"prefs.restore_page_defaults": "페이지 기본값 복원",
"prefs.ribbon_click_ends_tool": "실행 중인 도구의 버튼을 클릭하면 도구 종료",
@@ -2491,8 +2494,10 @@
"render.category_paint": "페인트",
"render.category_plastic": "플라스틱",
"render.category_wood": "목재",
"render.caustics_area_lights": "면광원 집광 효과",
"render.caustics_depth": "집광 경로 깊이",
"render.caustics_enable": "점광원 및 방향광의 유리 집광",
"render.caustics_environment": "환경광 집광 효과",
"render.caustics_help": "광자 수는 노이즈와 계산량을 조절합니다. 수집 반경은 세밀한 표현과 부드러운 추정 사이를 조절합니다. CPU에는 간접 조명이 필요합니다.",
"render.caustics_path_required": "CPU 집광에는 간접 조명이 필요합니다.",
"render.caustics_photons": "광자 수",
@@ -2669,7 +2674,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–64). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
"render.cpu_path_lighting": "간접 조명",
"render.cpu_path_optics_reason": "설정은 유지됩니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
"render.cpu_point": "점 조명",
@@ -2709,6 +2714,12 @@
"render.cpu_subsurface_denoise_reason": "CPU 내부 산란을 사용하려면 노이즈 제거를 끄세요. 설정은 유지됩니다.",
"render.cpu_subsurface_help": "산란은 닫힌 유리 내부에서 빛의 방향을 바꿉니다. 양수 비율에는 간접광을 사용하고 노이즈 제거를 끄세요. 비율은 mm당 충돌 횟수이며, 0은 산란을 추가하지 않습니다. 방향 편향이 양수이면 전방, 음수이면 후방을 선호합니다.",
"render.cpu_summary": "CPU 레이 트레이싱 · 반사 · HDR · 방향성 및 점 조명",
"render.cpu_threads": "CPU 작업 스레드",
"render.cpu_threads_auto": "자동",
"render.cpu_threads_custom": "사용자 지정",
"render.cpu_threads_help": "자동은 사용 가능한 CPU 작업 스레드를 사용합니다. 단일 스레드는 순차적으로 실행됩니다. 스레드가 많을수록 메모리가 더 필요할 수 있습니다.",
"render.cpu_threads_single": "단일",
"render.cpu_threads_web": "브라우저 렌더링은 하나의 협력형 작업 스레드를 사용합니다. 저장된 데스크톱 스레드 설정은 유지됩니다.",
"render.cpu_transparent_help": "투명 PNG는 배경을 제거하고 가장자리 커버리지를 유지합니다. 바디와 면의 불투명도는 100%여야 합니다.",
"render.cpu_volume_enable_path": "간접 조명 켜기",
"render.cpu_volume_enable_path_plan": "CPU 레이 트레이싱 · 경로 깊이 {depth} · 픽셀당 {spp}샘플 · 시드 {seed} · 레이 트레이싱 그림자. 작성한 대기와 이미지 예산을 유지합니다.",
@@ -2717,6 +2728,40 @@
"render.cpu_volume_method_incompatible": "이 조명, 샘플링 또는 대기 설정을 이 방식으로 옮길 수 없습니다. 설정은 유지됩니다.",
"render.cpu_volume_path_required": "CPU 대기를 사용하려면 간접 조명을 켜세요.",
"render.cpu_warm_cool": "따뜻함과 차가움",
"render.denoise_class_background": "건너뜀: 배경",
"render.denoise_class_coverage": "건너뜀: 부분 커버리지",
"render.denoise_class_degenerate": "건너뜀: 퇴화 경로",
"render.denoise_class_depth": "건너뜀: 경로 깊이 한도",
"render.denoise_class_emitter": "건너뜀: 발광체",
"render.denoise_class_exit": "건너뜀: 호환되지 않는 출사",
"render.denoise_class_identity": "건너뜀: 가이드 식별자 한도",
"render.denoise_class_lens": "건너뜀: 피사계 심도",
"render.denoise_class_material": "건너뜀: 지원되지 않는 재질",
"render.denoise_class_nested": "건너뜀: 중첩된 매질",
"render.denoise_class_optical": "적용 가능: 굴절을 통해 보이는 표면",
"render.denoise_class_reflection": "건너뜀: 반사 종점",
"render.denoise_class_surface": "적용 가능: 표면",
"render.denoise_class_tir": "건너뜀: 전반사",
"render.denoise_class_transmission": "건너뜀: 추가 투과",
"render.denoise_class_unavailable": "건너뜀: 가이드 없음",
"render.denoise_class_volume": "건너뜀: 볼륨 산란",
"render.denoise_diagnostics_counts": "적용 가능: {eligible} · 건너뜀: {skipped}",
"render.denoise_diagnostics_enable": "노이즈 제거 진단 수집",
"render.denoise_diagnostics_grid": "내부 크기 {width} × {height} 기준 픽셀 수입니다.",
"render.denoise_diagnostics_help": "노이즈 제거가 꺼져 있어도 적용 가능 픽셀과 건너뛴 픽셀 수를 수집합니다. 집계는 내부 렌더링 크기 기준이며 마스크는 출력 크기를 사용합니다.",
"render.denoise_diagnostics_title": "노이즈 제거 진단",
"render.denoise_export_mask": "마스크 PNG 내보내기",
"render.denoise_firefly": "고립된 밝은 픽셀 억제",
"render.denoise_format_mask": "마스크 PNG",
"render.denoise_mask_copy_help": "노이즈 제거 마스크를 PNG로 내보내거나 렌더링 이미지로 돌아가 이미지를 복사하세요.",
"render.denoise_mask_unavailable": "노이즈 제거 진단을 켜고 이미지를 렌더링하면 마스크를 확인할 수 있습니다.",
"render.denoise_optical_filter": "거울과 유리 필터링",
"render.denoise_optical_filter_help": "필터링 가능한 반사광과 투과광을 처리합니다. 끄면 불투명한 표면의 노이즈만 제거합니다.",
"render.denoise_passes": "필터 반복 횟수",
"render.denoise_settings_help": "반복 횟수가 많을수록 더 넓은 영역을 부드럽게 합니다. 강도는 각 단계에서 적용하는 필터의 정도를 조절합니다. 지원되지 않는 광 경로에는 필터를 적용하지 않습니다.",
"render.denoise_strength": "노이즈 제거 강도",
"render.denoise_view_image": "렌더링 이미지",
"render.denoise_view_mask": "노이즈 제거 마스크",
"render.diag_bodies": "표시된 바디",
"render.diag_job_details": "캡처된 작업 세부 정보",
"render.diag_last_check": "마지막 사전 점검",
@@ -3269,6 +3314,8 @@
"render.material_transient": "이 문서 탭의 임시 광학 재정의입니다. 디자인, 렌더 설정, 재질 라이브러리에는 저장되지 않습니다.",
"render.material_v_up": "텍스처 V축은 이미지 위쪽",
"render.material_weight": "코트 가중치 (0~1)",
"render.metal_energy_compensation": "거친 금속의 반사 복원",
"render.metal_energy_compensation_help": "거친 금속의 미세면 사이에서 손실되는 빛을 보정합니다. CPU 렌더링에는 간접 조명이 필요합니다.",
"render.method_cpu": "CPU 레이 트레이싱",
"render.method_gpu": "GPU 패스 트레이싱",
"render.method_limit_raster": "래스터는 고정된 불투명 스튜디오 미리보기를 사용합니다. 지원되는 광학 컨트롤을 쓰려면 명시적인 레이 트레이싱 방식을 선택하세요.",
@@ -3397,6 +3444,8 @@
"render.quality_denoise_on": "노이즈 제거: 켜짐",
"render.quality_draft": "초안",
"render.quality_high": "높음",
"render.quality_legacy_samples": "저장된 값: {samples} spp. 새 선택값은 {maximum} spp로 제한됩니다.",
"render.quality_preset_limit": "이 프리셋은 선택 한도인 16384 spp를 초과합니다. 샘플 수를 16384 이하로 선택하세요.",
"render.quality_projection_help": "이 검토에서는 해상도, 샘플 예산, 경로 깊이, 시드 및 노이즈 제거만 변경합니다. 다른 설정은 유지되며 수렴은 보장되지 않습니다.",
"render.quality_review": "샘플링 예산 검토",
"render.quality_summary": "{width} × {height} · 샘플 {samples}개 · 깊이 {depth} · 시드 {seed} · {denoise}",
@@ -3543,6 +3592,7 @@
"render.show_shots_label": "샷 스트립 표시",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "사용자 지정…",
"render.size_full_hd": "풀 HD",
"render.size_hd": "HD",
@@ -3705,6 +3755,11 @@
"render.subsurface_enable": "내부 산란 랜덤 워크",
"render.subsurface_help": "닫힌 유전체 전용입니다. 산란과 RGB 흡수는 mm당 별도 계수입니다. 경로는 실제 형상 내부에서 산란한 뒤 경계에서 굴절하거나 반사합니다. 직선 그림자 투과, 표면 노이즈 제거, 외부 볼륨을 끄고 프레넬 층 하나를 사용하세요. 확산 셰이더, 밀도 그리드, 측정된 재질이 아닙니다. 밀도가 높은 매질에는 더 많은 샘플과 경로 깊이가 필요할 수 있습니다.",
"render.subsurface_rate": "산란 충돌률",
"render.supersampling": "슈퍼샘플링",
"render.supersampling_dimensions": "{width} × {height}로 렌더링한 뒤 {output_width} × {output_height}로 축소합니다. 픽셀 수가 네 배가 됩니다.",
"render.supersampling_help": "가로와 세로를 각각 두 배로 렌더링한 뒤 선형 광 공간에서 축소합니다. 출력 크기는 그대로 유지됩니다.",
"render.supersampling_off": "끄기",
"render.supersampling_x2": "2×",
"render.surface_import.author": "작성자",
"render.surface_import.busy": "이미지 가져오는 중…",
"render.surface_import.complete": "이미지를 선택하고 작성자, 출처, 라이선스를 입력하세요.",
@@ -3718,6 +3773,8 @@
"render.surface_import.saved_cpu": "CPU 렌더링에서 사용하려면 맵을 저장하세요.",
"render.surface_import.source": "출처",
"render.surface_import.title": "이미지 가져오기",
"render.texture_filtering": "텍스처 세부 필터링",
"render.texture_filtering_help": "밉 레벨과 비등방성 필터링으로 축소 시 텍스처 계단 현상을 줄입니다. 메모리를 더 사용하며 끄면 기존 필터링을 유지합니다.",
"render.tone_aces_fitted": "ACES 근사",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU 유리는 보증된 분리 매질을 지원합니다. 제한 없는 중첩 매질과 노이즈 없는 투과 추정기는 제공되지 않습니다.",
+59 -1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "Dừng lịch sử",
"cmd.timeline_suppress": "Tạm nén tính năng",
"cmd.ui.assistant": "Bật/tắt trợ lý",
"cmd.ui.palette": "Tìm kiếm lệnh",
"cmd.view.navigator": "Khối điều hướng góc nhìn tùy chỉnh",
"cmd.view.render_browser": "Trình duyệt kết xuất",
"cmd.view.render_dock": "Di chuyển neo kết xuất",
@@ -2431,6 +2432,7 @@
"parts.dimension.width": "Bề rộng",
"parts.family.bearing": "Ổ bi cầu rãnh sâu (bao ngoài)",
"parts.family.button_head_cap_screw": "Vít đầu tròn lục giác chìm",
"parts.family.countersunk_socket_screw": "Vít đầu chìm lục giác chìm",
"parts.family.dowel_pin": "Chốt trụ",
"parts.family.hex_bolt": "Bu lông đầu lục giác",
"parts.family.hex_cap_screw_inch": "Bu lông đầu lục giác (inch)",
@@ -2826,6 +2828,8 @@
"prefs.remember_dialog_values.help": "Tắt theo mặc định. Khi bật, sau khi lệnh thành công, các giá trị và tùy chọn an toàn của lệnh sẽ được dùng lại vào lần tiếp theo bạn mở cùng lệnh đó, cho đến khi SolidCraft đóng.",
"prefs.render_legacy_image_defaults": "Dùng mặc định ảnh Kết xuất trước đây",
"prefs.render_legacy_image_defaults_help": "Giữ quy trình hình ảnh thủ công trước đây: thiết lập mới dùng raster CPU, dò tia CPU dùng lấy mẫu cố định và mở kết quả thủ công. Căn vừa lại khung nhìn khi vào Kết xuất, chuyển góc chụp mà không khôi phục camera đã lưu, đồng thời dùng cách lấy khung hình và điều hướng trước đây. Tắt phản hồi ảnh trực tiếp tự động. Các thiết lập đã lưu không thay đổi.",
"prefs.render_legacy_memory_admission": "Dùng cách kiểm tra bộ nhớ kết xuất trước đây",
"prefs.render_legacy_memory_admission_help": "Cho phép kết xuất gốc trong giới hạn bộ nhớ dùng chung và của máy chủ cố định, ngay cả khi mức ước tính vượt quá RAM khả dụng. Giới hạn thiết bị và kiểm tra cấp phát bộ nhớ vẫn được áp dụng.",
"prefs.render_previous_geometry_budget": "Dùng ngân sách hình học trước đây cho bản xem trước kết xuất",
"prefs.render_previous_geometry_budget_help": "Khôi phục giới hạn hình học cố định trước đây (512 MiB trên máy tính 64 bit, 128 MiB trên các nền tảng khác) thay cho ngân sách dựa trên thiết bị.",
"prefs.reports_saved": "Đã lưu {n}",
@@ -3220,8 +3224,10 @@
"render.category_paint": "Sơn",
"render.category_plastic": "Nhựa",
"render.category_wood": "Gỗ",
"render.caustics_area_lights": "Tụ quang từ đèn diện tích",
"render.caustics_depth": "Độ sâu đường tụ quang",
"render.caustics_enable": "Tụ quang qua kính từ nguồn điểm và nguồn định hướng",
"render.caustics_environment": "Tụ quang từ ánh sáng môi trường",
"render.caustics_help": "Số photon điều chỉnh nhiễu và khối lượng tính toán. Bán kính thu thập đánh đổi chi tiết sắc nét để có ước lượng mượt hơn. CPU cần chiếu sáng gián tiếp.",
"render.caustics_path_required": "Tụ quang trên CPU cần chiếu sáng gián tiếp.",
"render.caustics_photons": "Số photon",
@@ -3398,7 +3404,7 @@
"render.cpu_opaque_required": "Dò tia trên CPU yêu cầu nền đục. Hãy bỏ chọn 'Nền trong suốt' để tiếp tục.",
"render.cpu_options_pending": "Bảng điều khiển CPU này chưa hỗ trợ chỉnh sửa tự phát xạ, hiệu ứng bề mặt, thủy tinh hoặc bất đẳng hướng. Hãy xóa các tùy chọn API đó trước khi sử dụng.",
"render.cpu_path_depth": "Độ sâu đường truyền",
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–16). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–64). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_path_lighting": "Chiếu sáng gián tiếp",
"render.cpu_path_optics_reason": "Các thiết lập được giữ nguyên. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_point": "Nguồn sáng điểm",
@@ -3438,6 +3444,12 @@
"render.cpu_subsurface_denoise_reason": "Tán xạ bên trong kính trên CPU cần tắt Khử nhiễu. Các cài đặt được giữ nguyên.",
"render.cpu_subsurface_help": "Tán xạ đổi hướng ánh sáng bên trong kính kín. Tốc độ dương cần Chiếu sáng gián tiếp và tắt Khử nhiễu. Tốc độ là số va chạm mỗi mm; 0 không thêm tán xạ. Độ lệch hướng dương ưu tiên phía trước, âm ưu tiên phía sau.",
"render.cpu_summary": "Dò tia CPU · phản xạ · HDR · nguồn sáng định hướng và điểm",
"render.cpu_threads": "Luồng CPU",
"render.cpu_threads_auto": "Tự động",
"render.cpu_threads_custom": "Tùy chỉnh",
"render.cpu_threads_help": "Chế độ tự động dùng các luồng CPU khả dụng. Một luồng chạy tuần tự. Nhiều luồng hơn có thể cần nhiều bộ nhớ hơn.",
"render.cpu_threads_single": "Một luồng",
"render.cpu_threads_web": "Kết xuất trong trình duyệt dùng một luồng phối hợp. Lựa chọn luồng đã lưu cho ứng dụng máy tính được giữ nguyên.",
"render.cpu_transparent_help": "PNG trong suốt loại bỏ nền và giữ độ phủ ở biên. Độ đục của thân và mặt phải giữ ở 100%.",
"render.cpu_volume_enable_path": "Bật chiếu sáng gián tiếp",
"render.cpu_volume_enable_path_plan": "Dò tia CPU · độ sâu đường đi {depth} · {spp} mẫu/pixel · hạt giống {seed} · bóng dò tia. Giữ nguyên khí quyển và ngân sách ảnh đã đặt.",
@@ -3446,6 +3458,40 @@
"render.cpu_volume_method_incompatible": "Không thể chuyển các thiết lập đèn, lấy mẫu hoặc khí quyển này sang phương pháp này. Thiết lập của bạn được giữ nguyên.",
"render.cpu_volume_path_required": "Bật chiếu sáng gián tiếp để dùng Khí quyển trên CPU.",
"render.cpu_warm_cool": "Ấm và lạnh",
"render.denoise_class_background": "Bỏ qua: nền",
"render.denoise_class_coverage": "Bỏ qua: độ phủ một phần",
"render.denoise_class_degenerate": "Bỏ qua: đường truyền suy biến",
"render.denoise_class_depth": "Bỏ qua: giới hạn độ sâu đường truyền",
"render.denoise_class_emitter": "Bỏ qua: nguồn phát sáng",
"render.denoise_class_exit": "Bỏ qua: điểm thoát không tương thích",
"render.denoise_class_identity": "Bỏ qua: giới hạn định danh hướng dẫn",
"render.denoise_class_lens": "Bỏ qua: độ sâu trường ảnh",
"render.denoise_class_material": "Bỏ qua: vật liệu không hỗ trợ",
"render.denoise_class_nested": "Bỏ qua: môi trường lồng nhau",
"render.denoise_class_optical": "Đủ điều kiện: bề mặt qua khúc xạ",
"render.denoise_class_reflection": "Bỏ qua: điểm cuối phản xạ",
"render.denoise_class_surface": "Đủ điều kiện: bề mặt",
"render.denoise_class_tir": "Bỏ qua: phản xạ toàn phần",
"render.denoise_class_transmission": "Bỏ qua: truyền qua tiếp",
"render.denoise_class_unavailable": "Bỏ qua: không có dữ liệu hướng dẫn",
"render.denoise_class_volume": "Bỏ qua: tán xạ thể tích",
"render.denoise_diagnostics_counts": "Đủ điều kiện: {eligible} · Bỏ qua: {skipped}",
"render.denoise_diagnostics_enable": "Thu thập chẩn đoán khử nhiễu",
"render.denoise_diagnostics_grid": "Số pixel ở kích thước nội bộ {width} × {height}.",
"render.denoise_diagnostics_help": "Thu thập số pixel đủ điều kiện và bị bỏ qua ngay cả khi tắt khử nhiễu. Số lượng dựa trên kích thước kết xuất nội bộ; mặt nạ dùng kích thước đầu ra.",
"render.denoise_diagnostics_title": "Chẩn đoán khử nhiễu",
"render.denoise_export_mask": "Xuất mặt nạ PNG",
"render.denoise_firefly": "Giảm các pixel sáng đơn lẻ",
"render.denoise_format_mask": "PNG mặt nạ",
"render.denoise_mask_copy_help": "Xuất mặt nạ khử nhiễu dưới dạng PNG, hoặc quay lại ảnh đã kết xuất để sao chép.",
"render.denoise_mask_unavailable": "Bật chẩn đoán khử nhiễu và kết xuất ảnh để kiểm tra mặt nạ.",
"render.denoise_optical_filter": "Lọc gương và kính",
"render.denoise_optical_filter_help": "Lọc ánh sáng phản xạ và truyền qua đủ điều kiện. Tắt để chỉ khử nhiễu các bề mặt không trong suốt.",
"render.denoise_passes": "Số lượt lọc",
"render.denoise_settings_help": "Nhiều lượt lọc hơn làm mịn vùng rộng hơn. Cường độ điều chỉnh mức lọc áp dụng trong mỗi lượt. Các đường truyền quang học không được hỗ trợ vẫn không được lọc.",
"render.denoise_strength": "Cường độ khử nhiễu",
"render.denoise_view_image": "Ảnh đã kết xuất",
"render.denoise_view_mask": "Mặt nạ khử nhiễu",
"render.diag_bodies": "Số khối hiển thị",
"render.diag_job_details": "Chi tiết tác vụ đã ghi nhận",
"render.diag_last_check": "Lần kiểm tra sơ bộ gần nhất",
@@ -4000,6 +4046,8 @@
"render.material_transient": "Ghi đè quang học tạm thời cho tab tài liệu này. Chúng không được lưu trong thiết kế, thiết lập kết xuất hoặc thư viện vật liệu.",
"render.material_v_up": "Trục V kết cấu hướng lên trên trong ảnh",
"render.material_weight": "Trọng số lớp phủ bóng (0–1)",
"render.metal_energy_compensation": "Khôi phục phản xạ của kim loại nhám",
"render.metal_energy_compensation_help": "Bù ánh sáng thất thoát giữa các vi diện của kim loại nhám. Kết xuất CPU cần chiếu sáng gián tiếp.",
"render.method_cpu": "Dò tia trên CPU",
"render.method_gpu": "Dò đường truyền tia trên GPU (Path Tracing)",
"render.method_limit_raster": "Quét mảng (Raster) dùng bản xem trước studio đục cố định. Chọn phương thức dò tia rõ ràng để dùng các điều khiển quang học được hỗ trợ.",
@@ -4128,6 +4176,8 @@
"render.quality_denoise_on": "Giảm nhiễu: Bật",
"render.quality_draft": "Bản nháp",
"render.quality_high": "Cao",
"render.quality_legacy_samples": "Giá trị đã lưu: {samples} mẫu/pixel. Lựa chọn mới được giới hạn ở {maximum} mẫu/pixel.",
"render.quality_preset_limit": "Thiết lập sẵn này vượt quá giới hạn 16384 mẫu/pixel. Hãy chọn số mẫu tối đa là 16384.",
"render.quality_projection_help": "Bản xem xét này chỉ thay đổi độ phân giải, ngân sách mẫu, độ sâu đường đi, hạt giống và giảm nhiễu. Các thiết lập khác được giữ nguyên; ngân sách không chứng nhận ảnh đã hội tụ.",
"render.quality_review": "Xem xét định mức lấy mẫu",
"render.quality_summary": "{width} × {height} · {samples} mẫu · độ sâu {depth} · hạt giống {seed} · {denoise}",
@@ -4275,6 +4325,7 @@
"render.show_shots_label": "Hiện dải cảnh chụp",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Tùy chỉnh…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4437,6 +4488,11 @@
"render.subsurface_enable": "Bước đi ngẫu nhiên tán xạ bên trong",
"render.subsurface_help": "Chỉ dành cho chất điện môi kín. Hệ số tán xạ và hấp thụ RGB là các hệ số riêng biệt trên mỗi mm. Tia sáng tán xạ bên trong hình học thực tế, sau đó khúc xạ hoặc phản xạ tại ranh giới. Hãy tắt bóng đổ thẳng, khử nhiễu bề mặt và thể tích bên ngoài; dùng một phân tầng Fresnel. Đây không phải bộ đổ bóng khuếch tán, lưới mật độ hay vật liệu đo đạc. Môi trường đậm đặc có thể cần nhiều mẫu và độ sâu tia lớn hơn.",
"render.subsurface_rate": "Tần suất va chạm tán xạ",
"render.supersampling": "Lấy mẫu siêu phân giải",
"render.supersampling_dimensions": "Kết xuất ở {width} × {height} rồi thu nhỏ về {output_width} × {output_height}. Sử dụng số pixel gấp bốn lần.",
"render.supersampling_help": "Kết xuất với chiều rộng và chiều cao gấp đôi, rồi thu nhỏ trong không gian ánh sáng tuyến tính. Kích thước đầu ra không đổi.",
"render.supersampling_off": "Tắt",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Tác giả",
"render.surface_import.busy": "Đang nhập ảnh…",
"render.surface_import.complete": "Chọn ảnh rồi nhập tác giả, nguồn và giấy phép.",
@@ -4450,6 +4506,8 @@
"render.surface_import.saved_cpu": "Lưu bản đồ để dùng khi kết xuất bằng CPU.",
"render.surface_import.source": "Nguồn",
"render.surface_import.title": "Nhập ảnh",
"render.texture_filtering": "Lọc chi tiết kết cấu",
"render.texture_filtering_help": "Dùng các mức mip và lọc bất đẳng hướng để giảm răng cưa khi thu nhỏ kết cấu. Tốn thêm bộ nhớ; tắt để giữ cách lọc trước đây.",
"render.tone_aces_fitted": "ACES khớp",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "Thủy tinh GPU hỗ trợ các môi trường rời rạc đã xác thực. Không cung cấp môi trường lồng nhau không giới hạn hay ước tính truyền dẫn không nhiễu.",
+59 -1
View File
@@ -1509,6 +1509,7 @@
"cmd.timeline_stop": "停止历史播放",
"cmd.timeline_suppress": "抑制特征",
"cmd.ui.assistant": "切换助手显示",
"cmd.ui.palette": "搜索命令",
"cmd.view.navigator": "自定义视图导航器",
"cmd.view.render_browser": "渲染浏览器",
"cmd.view.render_dock": "移动渲染停靠面板",
@@ -2431,6 +2432,7 @@
"parts.dimension.width": "宽度",
"parts.family.bearing": "深沟球轴承(外形)",
"parts.family.button_head_cap_screw": "圆头内六角螺钉",
"parts.family.countersunk_socket_screw": "沉头内六角螺钉",
"parts.family.dowel_pin": "圆柱销",
"parts.family.hex_bolt": "六角头螺栓",
"parts.family.hex_cap_screw_inch": "六角头螺栓(英制)",
@@ -2826,6 +2828,8 @@
"prefs.remember_dialog_values.help": "默认关闭。开启后,命令成功执行后,在关闭 SolidCraft 前,下次打开同一命令时会重用其安全的值和选项。",
"prefs.render_legacy_image_defaults": "使用以前的渲染图像默认值",
"prefs.render_legacy_image_defaults_help": "保留以前的手动图像流程:新设置使用 CPU 光栅渲染,CPU 光线追踪使用固定采样,并手动打开结果。进入渲染时重新适配视图,切换镜头时不恢复其保存的相机,并使用以前的取景和导航方式。禁用自动实时图像反馈。已保存的设置保持不变。",
"prefs.render_legacy_memory_admission": "使用以前的渲染内存检查",
"prefs.render_legacy_memory_admission_help": "即使估算用量超过可用RAM,也允许在固定的共享内存和主机内存限额内进行原生渲染。设备限制和内存分配检查仍然适用。",
"prefs.render_previous_geometry_budget": "使用先前的渲染预览几何内存预算",
"prefs.render_previous_geometry_budget_help": "恢复先前的固定几何内存上限(64位桌面为512 MiB,其他平台为128 MiB),替代基于设备的预算。",
"prefs.reports_saved": "已保存 {n} 份",
@@ -3220,8 +3224,10 @@
"render.category_paint": "涂漆",
"render.category_plastic": "塑料",
"render.category_wood": "木材",
"render.caustics_area_lights": "面光源焦散",
"render.caustics_depth": "焦散路径深度",
"render.caustics_enable": "点光源和方向光的玻璃焦散",
"render.caustics_environment": "环境光焦散",
"render.caustics_help": "光子数控制噪点和计算量。增大收集半径可得到更平滑的估计,但会损失细节。CPU需要间接照明。",
"render.caustics_path_required": "CPU焦散需要间接照明。",
"render.caustics_photons": "光子数",
@@ -3398,7 +3404,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–64)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
"render.cpu_path_lighting": "间接照明",
"render.cpu_path_optics_reason": "设置会保留。使用自定义反射或各向异性时,请关闭间接照明。",
"render.cpu_point": "点光源",
@@ -3438,6 +3444,12 @@
"render.cpu_subsurface_denoise_reason": "CPU 内部散射需要关闭降噪。设置会保留。",
"render.cpu_subsurface_help": "散射会改变闭合玻璃内部的光线方向。正散射率需要开启间接光照并关闭降噪。散射率表示每毫米的碰撞次数;0 不增加散射。方向偏置为正时偏向前方,为负时偏向后方。",
"render.cpu_summary": "CPU光线追踪 · 反射 · HDR · 平行光与点光源",
"render.cpu_threads": "CPU 工作线程",
"render.cpu_threads_auto": "自动",
"render.cpu_threads_custom": "自定义",
"render.cpu_threads_help": "自动模式使用可用的 CPU 工作线程。单线程按顺序执行。更多线程可能需要更多内存。",
"render.cpu_threads_single": "单线程",
"render.cpu_threads_web": "浏览器渲染使用一个协作式工作线程。已保存的桌面线程选择会保留。",
"render.cpu_transparent_help": "透明PNG移除背景并保留边缘覆盖率。实体和面的不透明度必须保持100%。",
"render.cpu_volume_enable_path": "启用间接照明",
"render.cpu_volume_enable_path_plan": "CPU 光线追踪 · 路径深度 {depth} · 每像素 {spp} 个采样 · 种子 {seed} · 追踪阴影。保留已编写的大气和图像预算。",
@@ -3446,6 +3458,40 @@
"render.cpu_volume_method_incompatible": "这些灯光、采样或大气设置无法转移到此方式。您的设置会保留。",
"render.cpu_volume_path_required": "启用间接照明以使用 CPU 大气。",
"render.cpu_warm_cool": "暖色与冷色",
"render.denoise_class_background": "已跳过:背景",
"render.denoise_class_coverage": "已跳过:部分覆盖",
"render.denoise_class_degenerate": "已跳过:退化路径",
"render.denoise_class_depth": "已跳过:路径深度上限",
"render.denoise_class_emitter": "已跳过:发光体",
"render.denoise_class_exit": "已跳过:不兼容的出射",
"render.denoise_class_identity": "已跳过:引导标识上限",
"render.denoise_class_lens": "已跳过:景深",
"render.denoise_class_material": "已跳过:不支持的材质",
"render.denoise_class_nested": "已跳过:嵌套介质",
"render.denoise_class_optical": "可滤波:折射后的表面",
"render.denoise_class_reflection": "已跳过:反射终点",
"render.denoise_class_surface": "可滤波:表面",
"render.denoise_class_tir": "已跳过:全内反射",
"render.denoise_class_transmission": "已跳过:进一步透射",
"render.denoise_class_unavailable": "已跳过:无引导数据",
"render.denoise_class_volume": "已跳过:体积散射",
"render.denoise_diagnostics_counts": "可滤波:{eligible} · 已跳过:{skipped}",
"render.denoise_diagnostics_enable": "收集降噪诊断信息",
"render.denoise_diagnostics_grid": "内部尺寸 {width} × {height} 下的像素计数。",
"render.denoise_diagnostics_help": "即使关闭降噪,也会收集可滤波和跳过的像素数量。计数基于内部渲染尺寸;蒙版使用输出尺寸。",
"render.denoise_diagnostics_title": "降噪诊断",
"render.denoise_export_mask": "导出蒙版 PNG",
"render.denoise_firefly": "抑制孤立亮点",
"render.denoise_format_mask": "蒙版 PNG",
"render.denoise_mask_copy_help": "将降噪蒙版导出为 PNG,或返回渲染图像后复制图像。",
"render.denoise_mask_unavailable": "启用降噪诊断并渲染图像后,即可查看其蒙版。",
"render.denoise_optical_filter": "过滤镜面和玻璃",
"render.denoise_optical_filter_help": "对符合条件的反射光和透射光进行过滤。关闭后,仅对不透明表面降噪。",
"render.denoise_passes": "滤波轮数",
"render.denoise_settings_help": "更多轮次会平滑更大范围。强度控制每轮滤波的应用程度。不支持的光路保持未滤波状态。",
"render.denoise_strength": "降噪强度",
"render.denoise_view_image": "渲染图像",
"render.denoise_view_mask": "降噪蒙版",
"render.diag_bodies": "可见实体数",
"render.diag_job_details": "已捕获任务详情",
"render.diag_last_check": "上次预检",
@@ -4000,6 +4046,8 @@
"render.material_transient": "此文档标签页的临时光学设置,不保存到设计、渲染设置或材质库中。",
"render.material_v_up": "纹理 V 轴指向图像上方",
"render.material_weight": "涂层权重(0–1)",
"render.metal_energy_compensation": "恢复粗糙金属的反射",
"render.metal_energy_compensation_help": "补偿粗糙金属微表面之间损失的光能。CPU 渲染需要间接照明。",
"render.method_cpu": "CPU 光线追踪",
"render.method_gpu": "GPU 路径追踪",
"render.method_limit_raster": "光栅化使用固定的不透明工作室预览;要使用受支持的光学控件,请明确选择光线追踪方式。",
@@ -4128,6 +4176,8 @@
"render.quality_denoise_on": "降噪:开启",
"render.quality_draft": "草稿",
"render.quality_high": "高",
"render.quality_legacy_samples": "已保存的值:{samples} spp。新选择的上限为 {maximum} spp。",
"render.quality_preset_limit": "此预设超过 16384 spp 的选择上限。请选择不超过 16384 的采样数。",
"render.quality_projection_help": "此检查仅更改分辨率、采样预算、路径深度、种子和降噪。其他设置保持不变,预算不保证收敛。",
"render.quality_review": "审阅采样预算",
"render.quality_summary": "{width} × {height} · {samples} 次采样 · 深度 {depth} · 种子 {seed} · {denoise}",
@@ -4275,6 +4325,7 @@
"render.show_shots_label": "显示镜头条",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K 超高清",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "自定义…",
"render.size_full_hd": "全高清",
"render.size_hd": "HD",
@@ -4437,6 +4488,11 @@
"render.subsurface_enable": "内部散射随机游走",
"render.subsurface_help": "仅适用于封闭介质。散射和RGB吸收使用独立的每毫米系数。路径在实际几何内部散射,并在边界折射或反射。关闭直线透射阴影、表面降噪和外部体积;使用一个菲涅耳分层。这不是扩散着色器、密度网格或实测材料。高密度可能需要更多样本和路径深度。",
"render.subsurface_rate": "散射碰撞率",
"render.supersampling": "超采样",
"render.supersampling_dimensions": "以 {width} × {height} 渲染,再缩小至 {output_width} × {output_height}。使用四倍像素。",
"render.supersampling_help": "以双倍宽度和高度渲染,然后在线性光空间中缩小。输出尺寸保持不变。",
"render.supersampling_off": "关闭",
"render.supersampling_x2": "2×",
"render.surface_import.author": "作者",
"render.surface_import.busy": "正在导入图像…",
"render.surface_import.complete": "请选择图像并填写作者、来源和许可。",
@@ -4450,6 +4506,8 @@
"render.surface_import.saved_cpu": "保存贴图以用于 CPU 渲染。",
"render.surface_import.source": "来源",
"render.surface_import.title": "导入图像",
"render.texture_filtering": "过滤纹理细节",
"render.texture_filtering_help": "使用多级纹理和各向异性过滤,减少纹理缩小时的锯齿。需要更多内存;关闭时保留原有过滤方式。",
"render.tone_aces_fitted": "ACES 拟合",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU 玻璃支持经过验证且互不相交的介质;不支持任意嵌套介质,也不提供无噪声透射估计器。",
+64
View File
@@ -0,0 +1,64 @@
//! CSV text fields, as every SolidCraft export writes them and the parameter import reads them.
//!
//! A spreadsheet reads a field that starts with `=`, `+`, `-` or `@` as a formula, so a part
//! numbered `=1+1` would show `2`. A field like that is written with a `'` first, which
//! spreadsheets show as text (OWASP's CSV Injection page). [`unguard`] takes the `'` off again
//! when SolidCraft reads its own file back.
use std::borrow::Cow;
/// The first characters that make a spreadsheet read a field as a formula, from OWASP's list.
/// The full-width forms act as formulas in some locales, such as Japanese, so they are here too.
const FORMULA_STARTS: [char; 11] = ['=', '+', '-', '@', '\t', '\r', '\n', '=', '+', '-', '@'];
/// What makes a field need quotes: a separator (`;` as well as `,`, since a spreadsheet set to
/// `;` splits there and the new cell could start a formula), a quote, a tab or a line break.
const NEEDS_QUOTES: [char; 6] = [',', ';', '"', '\t', '\r', '\n'];
/// A text field, quoted only when it needs quotes.
pub fn field(text: &str) -> String {
let text = guard(text);
if text.contains(NEEDS_QUOTES) { quote(&text) } else { text.into_owned() }
}
/// A text field, always in quotes.
pub fn quoted(text: &str) -> String {
quote(&guard(text))
}
/// A field as SolidCraft wrote it, with the `'` that [`field`] or [`quoted`] put first taken off.
/// A field without one is returned as it is.
pub fn unguard(text: &str) -> &str {
match text.strip_prefix('\'') {
Some(rest) if starts_a_formula(rest) => rest,
_ => text,
}
}
/// `text` with a `'` first when a spreadsheet would read it as a formula. A field that already
/// starts with `'` and then a formula gets one more, so [`unguard`] gives back exactly what was
/// written. A whole plain number (`-5`, `+1e-9`) is read as that number, not as a formula, so it
/// is left as it is.
fn guard(text: &str) -> Cow<'_, str> {
if starts_a_formula(text) && !plain_number(text) { Cow::Owned(format!("'{text}")) } else { Cow::Borrowed(text) }
}
/// Whether `text`, after any `'` and spaces it starts with, starts as a formula does. Spaces count
/// because a spreadsheet may take them off as it reads (LibreOffice's "Trim spaces").
fn starts_a_formula(text: &str) -> bool {
text.trim_start_matches(['\'', ' ']).starts_with(FORMULA_STARTS)
}
/// A finite number and nothing else, spaces included. Rust's `f64` parsing also takes `inf`,
/// `infinity` and `NaN` in any case, and none of them is finite.
fn plain_number(text: &str) -> bool {
text.parse::<f64>().is_ok_and(f64::is_finite)
}
fn quote(text: &str) -> String {
format!("\"{}\"", text.replace('"', "\"\""))
}
#[cfg(test)]
#[path = "csv_tests.rs"]
mod tests;
+89
View File
@@ -0,0 +1,89 @@
use super::{FORMULA_STARTS, NEEDS_QUOTES, field, quoted, unguard};
#[test]
fn a_field_a_spreadsheet_would_run_gets_an_apostrophe() {
for (text, written) in [
("=1+1", "'=1+1"),
("+2 for the fit", "'+2 for the fit"),
("-5 mm shim", "'-5 mm shim"),
("@SUM(1,1)", "\"'@SUM(1,1)\""),
("=1+1", "'=1+1"),
("+2", "'+2"),
("-5", "'-5"),
("@x", "'@x"),
("\tx", "\"'\tx\""),
("\rx", "\"'\rx\""),
("\nx", "\"'\nx\""),
// Already an apostrophe, then a formula: one more, so the import gives back the original.
("'=x", "''=x"),
("''-x", "'''-x"),
// Spaces before a formula count: a spreadsheet may take them off.
(" =x", "' =x"),
(" -5", "' -5"),
("' @x", "'' @x"),
] {
assert_eq!(field(text), written, "{text:?}");
assert_eq!(unguard(written.trim_matches('"')), text, "{written:?}");
}
}
#[test]
fn a_whole_plain_number_stays_a_number() {
for number in ["-5", "+5", "-0", "0", "1e-9", "-1.5E+3", ".5", "-.5", "5.", "+1e5", "-12.000000000"] {
assert_eq!(field(number), number, "{number:?}");
}
// Not plain numbers: Rust's parse takes the words, spaces aren't a number's, and one too large isn't finite.
for text in ["-inf", "+inf", "-infinity", "-NaN", "+nan", "-", "+", "--5", "-5 mm", "-5 ", "-1e400", "-0x10", "-5,5"] {
assert!(field(text).trim_start_matches('"').starts_with('\''), "{text:?}: {}", field(text));
}
}
#[test]
fn other_fields_are_left_as_they_are() {
for text in ["", "Plate", "PL-100", "a=b", "x+1", "'quoted' word", "'", "''", "5 mm", " 5", "é=", "μ-5"] {
assert_eq!(field(text), text, "{text:?}");
assert_eq!(unguard(text), text, "{text:?}");
}
}
#[test]
fn a_field_with_a_separator_a_quote_or_a_break_is_quoted() {
assert_eq!(field("Plate, 3 mm"), "\"Plate, 3 mm\"");
assert_eq!(field("one; two"), "\"one; two\"");
assert_eq!(field("Pin \"short\""), "\"Pin \"\"short\"\"\"");
assert_eq!(field("a\tb"), "\"a\tb\"");
assert_eq!(field("line one\r\nline two"), "\"line one\r\nline two\"");
assert_eq!(quoted("Plate"), "\"Plate\"");
assert_eq!(quoted("=A1"), "\"'=A1\"");
assert_eq!(quoted("-5"), "\"-5\"");
}
/// Random fields from an alphabet heavy in what matters: every formula start, apostrophes, quotes,
/// separators, spaces, digits and the letters of `inf` and `NaN`. Each one reads back exactly, never starts
/// a formula once written unless it is a whole number, and is quoted whenever it holds a separator.
#[test]
fn fuzz_every_field_reads_back_and_never_starts_a_formula() {
let mut alphabet: Vec<char> = "'''\"\";,.eE0123456789 infaNxé".chars().collect();
alphabet.extend(FORMULA_STARTS);
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for _ in 0..50_000 {
let n = (rnd() % 12) as usize;
let text: String = (0..n).filter_map(|_| alphabet.get((rnd() % alphabet.len() as u64) as usize)).collect();
let written = field(&text);
let inner = match written.strip_prefix('"').and_then(|w| w.strip_suffix('"')) {
Some(inner) => inner.replace("\"\"", "\""),
None => written.clone(),
};
assert_eq!(unguard(&inner), text, "{text:?} written as {written:?}");
let bare = inner.trim_start_matches(' ');
assert!(!bare.starts_with(FORMULA_STARTS) || text.parse::<f64>().is_ok_and(f64::is_finite), "{text:?} written as {written:?}");
assert!(!text.contains(NEEDS_QUOTES) || written.starts_with('"'), "{text:?} written as {written:?}");
assert_eq!(quoted(&text), format!("\"{}\"", inner.replace('"', "\"\"")), "{text:?}");
}
}
+34
View File
@@ -2294,6 +2294,10 @@ pub struct MeshData {
pub positions: std::sync::Arc<[f64]>,
/// Three vertex indices per triangle.
pub triangles: std::sync::Arc<[u32]>,
/// Unit shading normals, x/y/z for each triangle corner. Empty means flat shading.
/// Independent of the welded positions and the geometric boundary certificate.
#[serde(default, skip_serializing_if = "<[f64]>::is_empty")]
pub corner_normals: std::sync::Arc<[f64]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<[f32; 3]>,
}
@@ -2309,6 +2313,36 @@ impl PartialEq for MeshData {
&& self.color == other.color
&& (Arc::ptr_eq(&self.triangles, &other.triangles) || self.triangles == other.triangles)
&& (Arc::ptr_eq(&self.positions, &other.positions) || self.positions == other.positions)
&& (Arc::ptr_eq(&self.corner_normals, &other.corner_normals) || self.corner_normals == other.corner_normals)
}
}
impl MeshData {
/// Explicitly generate crease-aware shading for an existing imported source.
/// Coordinates, triangle indices, topology and body identity remain unchanged.
pub fn generate_crease_normals(&mut self, angle_degrees: f64) -> crate::Result<()> {
if !angle_degrees.is_finite()
|| !(0.0..=180.0).contains(&angle_degrees)
|| !self.positions.len().is_multiple_of(3)
|| !self.triangles.len().is_multiple_of(3)
{
return Err(crate::DocError::Invalid("mesh crease angle must be between 0 and 180 degrees with complete triangles".into()));
}
let allocation = |_| crate::DocError::Invalid("could not reserve mesh normal generation".into());
let mut positions = Vec::new();
positions.try_reserve_exact(self.positions.len() / 3).map_err(allocation)?;
positions.extend(self.positions.as_chunks::<3>().0.iter().map(|p| solidcore_record::Vec3::new(p[0], p[1], p[2])));
let mut triangles = Vec::new();
triangles.try_reserve_exact(self.triangles.len() / 3).map_err(allocation)?;
triangles.extend_from_slice(self.triangles.as_chunks::<3>().0);
let mesh = solidcraft_geom::Mesh { positions, triangles, ..Default::default() };
let normals = mesh.crease_corner_normals(angle_degrees.to_radians(), None).map_err(crate::DocError::Invalid)?;
let count = normals.len().checked_mul(9).ok_or_else(|| crate::DocError::Invalid("mesh normal count overflow".into()))?;
let mut values = Vec::new();
values.try_reserve_exact(count).map_err(allocation)?;
values.extend(normals.iter().flatten().flat_map(|n| [n.x, n.y, n.z]));
self.corner_normals = values.into();
Ok(())
}
}
+106 -29
View File
@@ -1227,12 +1227,15 @@ struct ImportDigests {
struct MeshDigest {
positions: Arc<[f64]>,
triangles: Arc<[u32]>,
corner_normals: Arc<[f64]>,
digest: u64,
}
impl MeshDigest {
fn matches(&self, mesh: &crate::MeshData) -> bool {
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
Arc::ptr_eq(&self.positions, &mesh.positions)
&& Arc::ptr_eq(&self.triangles, &mesh.triangles)
&& Arc::ptr_eq(&self.corner_normals, &mesh.corner_normals)
}
}
@@ -1251,7 +1254,12 @@ impl ImportDigests {
.enumerate()
.map(|(index, mesh)| {
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
MeshDigest {
positions: mesh.positions.clone(),
triangles: mesh.triangles.clone(),
corner_normals: mesh.corner_normals.clone(),
digest,
}
})
.collect();
out.meshes.insert(f.id, digests);
@@ -1280,6 +1288,9 @@ fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
coordinate.to_bits().hash(&mut h);
}
mesh.triangles.hash(&mut h);
for n in mesh.corner_normals.iter() {
n.to_bits().hash(&mut h);
}
h.finish()
}
@@ -1371,6 +1382,7 @@ fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
mesh.positions = Arc::from([]);
mesh.triangles = Arc::from([]);
mesh.corner_normals = Arc::from([]);
}
}
// Keep the complete definition (including mesh order, names and colors) without
@@ -4866,9 +4878,13 @@ fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at:
// Pick an actual cylindrical wall, not a depth derived from its radius: a large pipe bore can
// be shallow or pass through a thin plate. Sampling its finite face also skips a wider entry
// pocket and keeps metadata attached to the narrow cylindrical bore.
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
//
// A hole drilled across a pocket or a slot leaves its wall in pieces along the axis (#13505):
// every piece the hole reaches (`reach`, its depth from `entry`) is returned, nearest the entry
// first, each as (its start and end from the entry along `inward`, a point on it).
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64, reach: f64) -> Vec<(f64, f64, Vec3)> {
let tol = (radius * 1e-6).max(1e-6);
let mut best: Option<(f64, Vec3)> = None;
let mut walls: Vec<(f64, f64, Vec3)> = Vec::new();
for body in &st.bodies {
// A wall found lies on the line through `entry` along `inward`, give or take its radius
// (and the slant its axis may have off `inward` over the body's distance), and its
@@ -4895,12 +4911,15 @@ fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64)
let b = (c.axis_point + c.axis * c.end - entry).dot(inward);
let lo = a.min(b).max(0.0);
let hi = a.max(b);
if hi > lo + 1e-9 && best.as_ref().is_none_or(|(old, _)| lo < *old) {
best = Some((lo, entry + inward * ((lo + hi) * 0.5) + inward.any_perp() * radius));
// One piece of wall made of several faces (a cylinder split at its seams) is one.
let mid = (lo + hi) * 0.5;
if hi > lo + 1e-9 && lo < reach - 1e-6 && !walls.iter().any(|(l, h, _)| (*l..=*h).contains(&mid)) {
walls.push((lo, hi, entry + inward * mid + inward.any_perp() * radius));
}
}
}
best.map(|(_, wall)| wall)
walls.sort_by(|a, b| a.0.total_cmp(&b.0));
walls
}
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
@@ -7652,7 +7671,7 @@ fn eval_feature(
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
}
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, depth, to, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
@@ -7667,6 +7686,19 @@ fn eval_feature(
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
}
let (spots, dir) = hole_spots(vals, st, f)?;
// How deep each hole goes (through all: as far as the wall goes), read before the cut:
// the pieces of a wall the thread runs along lie within it.
let reaches: Vec<f64> = if thread.is_some() {
spots
.iter()
.map(|at| match hole_depth_to(st, at, dir, &to.map(Vec3::from), depth)? {
Some(e) => val(vals, &e, Kind::Length),
None => Ok(f64::INFINITY),
})
.collect::<Result<_>>()?
} else {
Vec::new()
};
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
@@ -7702,42 +7734,77 @@ fn eval_feature(
)
});
let ease = thread::runout(vals, ease_out)?;
for p in spots {
let wall = if pipe {
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
for (p, reach) in spots.into_iter().zip(reaches) {
let walls = parallel_tapped_wall(st, p, dir, r, reach);
if pipe && walls.is_empty() {
return Err(DocError::Invalid("no cylindrical pipe-thread wall remains".into()));
}
// A pipe thread is one taper from the entry: its first wall only.
let walls: Vec<(f64, f64, Vec3)> = match walls.first() {
None => vec![(0.0, f64::INFINITY, p + dir.any_perp() * r + dir * (r * 0.5))],
Some(first) if pipe => vec![*first],
Some(_) => walls,
};
// The thread depth runs from where the first wall starts, through every piece
// of the wall it reaches (#13505).
let first = walls.first().map_or(0.0, |w| w.0);
let stop = thread_depth.map(|d| first + d);
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
if *modeled {
// Cut from the entry, as deep as `thread_depth` or along the whole wall:
// at a blind hole's bottom the groove stops clear of it, or fades out
// before it with a runout.
let extent = thread_depth.map_or(thread::Extent::Full, thread::Extent::Length);
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(p), ease_out: ease, ..ask };
let on = thread_on(st, f.id, wall, &ask)?;
thread::cut(st, on, (crate::thread_forms::ThreadTip::None, None, None))?;
// before it with a runout. Every piece is found before any is cut.
let mut on = Vec::new();
for (lo, hi, wall) in &walls {
let extent = match stop {
None => thread::Extent::Full,
Some(s) if s >= *hi => thread::Extent::Full,
Some(s) if s > *lo => thread::Extent::Length(s - lo),
Some(_) => break,
};
// A piece past the first starts at its end nearest the entry.
let from = if on.is_empty() { p } else { p + dir * *lo };
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(from), ease_out: ease, ..ask };
on.push(thread_on(st, f.id, *wall, &ask)?);
}
for o in on {
thread::cut(st, o, (crate::thread_forms::ThreadTip::None, None, None))?;
}
continue;
}
let mut t = thread_on(st, f.id, wall, &ask)?.info;
let mut threads = Vec::new();
for (lo, _, wall) in &walls {
if stop.is_some_and(|s| s <= *lo) {
break;
}
threads.push(thread_on(st, f.id, *wall, &ask)?.info);
}
if let Some(depth) = thread_depth {
let available = t.end - t.start;
if depth > available {
let useful =
|t: &ThreadInfo| (t.axis_point + t.axis * t.start - p).dot(dir).max((t.axis_point + t.axis * t.end - p).dot(dir));
let available = threads.last().map_or(0.0, |t| useful(t) - first);
if depth > available + 1e-9 {
return Err(DocError::Invalid(format!(
"thread depth {} mm exceeds the cylindrical wall's useful length of {} mm; reduce the thread depth or use full thread depth",
show_mm(depth),
show_mm(available)
)));
}
// Start at the entry in the drilling direction, independently of the
// wall probe's position and the cylinder surface's axis orientation.
if dir.dot(t.axis) > 0.0 {
t.end = t.start + depth;
} else {
t.start = t.end - depth;
// Stop `depth` past the first wall's start in the drilling direction,
// independently of the wall probe's position and the cylinder surface's
// axis orientation.
if let (Some(t), Some(s)) = (threads.last_mut(), stop) {
let past = useful(t) - s;
if past > 0.0 {
if dir.dot(t.axis) > 0.0 {
t.end -= past;
} else {
t.start += past;
}
}
}
}
st.threads.push(t);
st.threads.extend(threads);
}
}
Ok(())
@@ -8534,7 +8601,17 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
for m in meshes {
let positions: Vec<Vec3> = m.positions.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
let triangles: Vec<[u32; 3]> = m.triangles.as_chunks::<3>().0.to_vec();
let b = kernel::mesh_body(&positions, &triangles).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
if !m.corner_normals.is_empty() && m.corner_normals.len() != m.triangles.len().saturating_mul(3) {
return Err(DocError::Invalid(format!("mesh `{}`: invalid shading normal count", m.name)));
}
let normals: Vec<[Vec3; 3]> = m
.corner_normals
.as_chunks::<9>()
.0
.iter()
.map(|c| [Vec3::new(c[0], c[1], c[2]), Vec3::new(c[3], c[4], c[5]), Vec3::new(c[6], c[7], c[8])])
.collect();
let b = kernel::mesh_body_with_normals(&positions, &triangles, &normals).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
out.push((m.name.clone(), b.with_color(m.color)));
}
Ok(out)
+15 -1
View File
@@ -911,6 +911,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., 0.]),
triangles: Arc::from([0, 1, 2]),
color: None,
corner_normals: Default::default(),
}],
},
None,
@@ -924,7 +925,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
Arc::make_mut(&mut doc).occurrences[0].transform = rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Z, 0.3);
assert!(!model.evaluate_shared(&doc));
assert!(Arc::ptr_eq(&original, &model.state()));
for edit in 0..5 {
for edit in 0..6 {
let FeatureKind::MeshImport { meshes, .. } = &mut Arc::make_mut(&mut doc).feature_mut(feature).unwrap().kind else { panic!("mesh") };
let mesh = &mut meshes[0];
match edit {
@@ -932,6 +933,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
1 => Arc::make_mut(&mut mesh.triangles).swap(1, 2),
2 => mesh.color = Some([0.2, 0.4, 0.6]),
3 => mesh.name = "Renamed".into(),
4 => mesh.corner_normals = Arc::from([0., 1., 1., 0., 1., 1., 0., 1., 1.]),
_ => mesh.positions = Arc::from(mesh.positions.to_vec()),
}
assert!(model.evaluate_shared(&doc), "mesh edit {edit} must invalidate");
@@ -1044,6 +1046,7 @@ fn comparing_a_copied_mesh_does_not_read_its_shared_arrays() {
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., f64::NAN]),
triangles: Arc::from([0, 1, 2]),
color: None,
corner_normals: Default::default(),
};
let copy = mesh.clone();
assert!(copy == mesh, "a copy shares the arrays and is the same mesh without reading them");
@@ -1771,6 +1774,7 @@ fn imported_meshes() -> Document {
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
color: Some([0.2, 0.4, 0.6]),
corner_normals: Default::default(),
})
.collect();
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
@@ -1802,6 +1806,16 @@ fn same_as_fresh(doc: &Document, model: &Model) {
#[test]
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
let mut doc = imported_meshes();
for mesh in meshes_mut(&mut doc) {
mesh.generate_crease_normals(10.0).unwrap();
assert!(!mesh.corner_normals.is_empty());
}
let compact: serde_json::Value = serde_json::from_str(&super::feature_json(&doc.features[0], &super::ImportDigests::default())).unwrap();
for mesh in compact[0]["meshes"].as_array().unwrap() {
assert_eq!(mesh["positions"], serde_json::json!([]));
assert_eq!(mesh["triangles"], serde_json::json!([]));
assert!(mesh.get("corner_normals").is_none(), "the fingerprint must hash the retained digest without serializing shading payloads");
}
let mut model = Model::new();
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
for k in 1..5 {
@@ -0,0 +1,33 @@
//! Imported mesh sources may save optional `corner_normals`: nine f64 values per
//! triangle, independent of positions, topology and optical boundary certification.
//! Missing/empty normals retain the previous flat shading. OBJ `vn` and `s` import
//! preserve authored corner normals and smoothing groups. Explicit crease generation
//! stores only shading metadata and preserves original coordinates and triangle indices.
//! Older released readers warn about this format and retain fields they cannot read.
//!
//! Render jobs may save opt-in `texture_filtering`; omission/false preserves LOD0
//! texture sampling. Supported consumers use the captured texture's mip chain and
//! ray footprint without changing source image identity or source color semantics.
//!
//! Optional `metal_energy_compensation` restores a bounded share of rough metal
//! reflectance in both path tracers. Omission or false keeps the previous BRDF.
//!
//! Caustics options may save `area_lights: true` and `environment: true` for
//! both path tracers. Omission preserves the hard-source photon map.
//!
//! Caustics may also save `guided_emission: true` to concentrate photon emission
//! around captured optical bounds with probability-corrected power. Omission or
//! false preserves the original emission proposal. Older released readers warn
//! about the newer format and retain fields they cannot interpret when saving.
//!
//! Caustics may save `refresh_per_pass: true` to build one photon map per camera
//! pass. Omission or false retains one map per render job.
//!
//! Caustics may save `power_weighted_sources: true` to allocate photon launches
//! using estimated source power with a uniform support component. Each source
//! retains its matching probability correction. Omission or false preserves
//! uniform source selection and the original random sequence.
//!
//! CPU jobs may save `environment_importance_sampling: true` to use the captured
//! HDR radiance distribution for lighting and photon emission. Omission or false
//! retains uniform sphere sampling. Older readers warn and retain unknown fields.
@@ -0,0 +1,6 @@
//! Saves CPU worker selection, linear 2x supersampling and denoising controls in render setups.
//! Older designs retain native-size output, three full-strength denoising passes and no firefly suppression.
//! DenoiseSettings.optical_filter defaults to true for supported mirror/glass filtering; an explicit false
//! restores legacy opaque-only denoising and is retained in CPU and GPU setups. No rewrite is needed.
//! Omitted CPU worker selection uses the bounded automatic pool, with one worker available for serial work.
//! The last release warns about this newer design format and preserves fields it cannot read.
+1
View File
@@ -22,6 +22,7 @@ mod component_removal;
pub use component_removal::RemovedTree;
pub mod config;
pub mod construct;
pub mod csv;
pub mod display_quality;
pub mod display_units;
mod document;
+11 -2
View File
@@ -50,8 +50,11 @@ pub struct EdgeHit {
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names exactly (see [`exactly`]). `Err` explains when there is none: no
/// such edge, the named edge split into several, or a name saved before format 14.
/// between the faces it names exactly (see [`exactly`]). A numbered name (`~k`) was one of
/// several edges between its faces, told apart only by where they lay, so the one edge left
/// between them is it only where the reference's point still lies on it. `Err` explains when
/// there is none: no such edge, the named edge split into several, one of several edges whose
/// faces now bound one edge elsewhere, or a name saved before format 14.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if legacy_name(name).is_some() {
return Err(format!("edge `{name}` was named by a build before format 14"));
@@ -81,7 +84,13 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
keep_best(&mut best, level, i);
}
let (_, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
// When the faces of several numbered edges bound one edge now, every one of their names
// finds the faces of that edge: taking it for each would put them all on one edge (#13332).
let numbered = wanted.len() != name.len();
match cands.as_slice() {
[i] if numbered && !edge_near(b, &m, *i, near) => {
Err(format!("edge `{name}` was one of several between its faces, and the one left is elsewhere"))
}
[i] => exact(*i),
_ => Err(format!("edge `{name}` is no longer one edge")),
}
+6 -4
View File
@@ -483,9 +483,10 @@ impl Document {
}
/// User parameters as CSV (`name,unit,expression,value,comment`). The `value` column is the
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`.
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`. A text
/// field a spreadsheet would run as a formula is written as text ([`crate::csv`]).
pub fn params_csv(&self) -> String {
let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
let q = crate::csv::field;
let mut out = String::from("name,unit,expression,value,comment,favorite\n");
for r in self.param_rows().into_iter().filter(|r| r.source == "user") {
out += &format!(
@@ -1137,13 +1138,14 @@ fn split_csv(text: &str, separator: char) -> impl Iterator<Item = &str> {
fn csv_fields(record: &str) -> Result<Vec<String>> {
split_csv(record, ',')
.map(|raw| {
// `unguard` takes off the `'` an export put before a value a spreadsheet would run.
let field = raw.trim();
if !field.contains('"') {
return Ok(field.to_string());
return Ok(crate::csv::unguard(field).to_string());
}
let inner = field.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
match inner {
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(s.replace("\"\"", "\"")),
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(crate::csv::unguard(&s.replace("\"\"", "\"")).to_string()),
_ => Err(DocError::Invalid("invalid quoting in parameter CSV".into())),
}
})
+6 -2
View File
@@ -240,8 +240,12 @@ fn same_feature(a: &Feature, b: &Feature) -> bool {
a == b
&& meshes.len() == other.len()
&& meshes.iter().zip(other).all(|(a, b)| {
let crate::MeshData { name, positions, triangles, color } = a;
*name == b.name && *color == b.color && Arc::ptr_eq(positions, &b.positions) && Arc::ptr_eq(triangles, &b.triangles)
let crate::MeshData { name, positions, triangles, corner_normals, color } = a;
*name == b.name
&& *color == b.color
&& Arc::ptr_eq(positions, &b.positions)
&& Arc::ptr_eq(triangles, &b.triangles)
&& Arc::ptr_eq(corner_normals, &b.corner_normals)
})
}
(
+101 -2
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use solidcraft_render::{
Camera,
job::{
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
CpuBudget, CpuProgressiveOptions, DenoiseSettings, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
},
lighting::environment::SkyGradient,
};
@@ -82,6 +82,18 @@ impl RenderJob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CpuJob {
/// Zero selects a bounded native worker count; one retains serial transport.
#[serde(skip_serializing_if = "zero_threads")]
pub threads: u16,
/// Spend HDR lighting samples according to captured environment radiance.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub environment_importance_sampling: bool,
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub texture_filtering: bool,
/// Approximate the missing microfacet bounces of rough isotropic conductors.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub metal_energy_compensation: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub caustics: Option<solidcraft_render::job::CausticOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -98,6 +110,13 @@ pub struct CpuJob {
pub path: Option<solidcraft_render::job::CpuPathOptions>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise: bool,
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
pub denoise_settings: DenoiseSettings,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise_diagnostics: bool,
/// Internal scale; saved width and height remain the exported image size.
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
pub supersampling: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub lens: Option<ThinLens>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -129,6 +148,10 @@ pub struct CpuJob {
impl Default for CpuJob {
fn default() -> Self {
Self {
threads: 0,
environment_importance_sampling: false,
texture_filtering: false,
metal_energy_compensation: false,
caustics: None,
volume: None,
sun: None,
@@ -136,6 +159,9 @@ impl Default for CpuJob {
aging: Default::default(),
adaptive: None,
denoise: false,
denoise_settings: DenoiseSettings::default(),
denoise_diagnostics: false,
supersampling: 1,
path: None,
lens: None,
output: None,
@@ -200,6 +226,10 @@ impl CpuJob {
})
.transpose()?;
let options = RaytraceOptions {
threads: self.threads,
environment_importance_sampling: self.environment_importance_sampling,
texture_filtering: self.texture_filtering,
metal_energy_compensation: self.metal_energy_compensation,
caustics: self.caustics,
volume: self.volume,
sun: self.sun.map(Box::new),
@@ -207,6 +237,9 @@ impl CpuJob {
aging: (!aging.is_empty()).then(|| Box::new(aging)),
adaptive: self.adaptive.map(Box::new),
denoise: self.denoise,
denoise_settings: self.denoise_settings,
denoise_diagnostics: self.denoise_diagnostics,
supersampling: self.supersampling,
path: self.path,
lens: self.lens,
output: self.output.clone(),
@@ -233,6 +266,12 @@ impl CpuJob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GpuJob {
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub texture_filtering: bool,
/// Approximate the missing microfacet bounces of rough isotropic conductors.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub metal_energy_compensation: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub caustics: Option<solidcraft_render::job::CausticOptions>,
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
@@ -255,6 +294,13 @@ pub struct GpuJob {
pub seed: u32,
pub tone_map: ToneMap,
pub denoise: bool,
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
pub denoise_settings: DenoiseSettings,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise_diagnostics: bool,
/// Internal scale; saved width and height remain the exported image size.
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
pub supersampling: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub lens: Option<ThinLens>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -269,6 +315,8 @@ impl Default for GpuJob {
fn default() -> Self {
let options = JobOptions::default();
Self {
texture_filtering: false,
metal_energy_compensation: false,
caustics: None,
aging: Default::default(),
nested_media: options.nested_media,
@@ -283,6 +331,9 @@ impl Default for GpuJob {
seed: options.seed,
tone_map: options.tone_map,
denoise: options.denoise,
denoise_settings: options.denoise_settings,
denoise_diagnostics: options.denoise_diagnostics,
supersampling: options.supersampling,
lens: options.lens,
lighting: options.lighting,
environment: None,
@@ -317,6 +368,8 @@ impl GpuJob {
environment.validate()?;
}
let options = JobOptions {
texture_filtering: self.texture_filtering,
metal_energy_compensation: self.metal_energy_compensation,
caustics: self.caustics,
aging: self.aging.parameters_only()?,
nested_media: self.nested_media,
@@ -331,6 +384,9 @@ impl GpuJob {
seed: self.seed,
tone_map: self.tone_map,
denoise: self.denoise,
denoise_settings: self.denoise_settings,
denoise_diagnostics: self.denoise_diagnostics,
supersampling: self.supersampling,
lens: self.lens,
lighting: self.lighting,
exposure_ev,
@@ -365,6 +421,10 @@ impl SkyEnvironment {
}
}
fn zero_threads(threads: &u16) -> bool {
*threads == 0
}
fn unit() -> f64 {
1.0
}
@@ -478,3 +538,42 @@ mod cpu_volume_portable_tests {
assert_eq!(serde_json::to_value(job).unwrap(), old);
}
}
#[cfg(test)]
#[test]
fn portable_quality_controls_preserve_explicit_values_and_legacy_defaults() {
let old_cpu: CpuJob = serde_json::from_str("{}").unwrap();
let old_gpu: GpuJob = serde_json::from_str("{}").unwrap();
assert_eq!(old_cpu.threads, 0);
assert_eq!((old_cpu.supersampling, old_gpu.supersampling), (1, 1));
assert_eq!(old_cpu.denoise_settings, DenoiseSettings::default());
assert_eq!(old_gpu.denoise_settings, DenoiseSettings::default());
assert!(!old_cpu.denoise_diagnostics && !old_gpu.denoise_diagnostics);
assert!(old_cpu.denoise_settings.optical_filter && old_gpu.denoise_settings.optical_filter);
let opaque_only = DenoiseSettings { optical_filter: false, ..Default::default() };
for wire in [
serde_json::to_value(CpuJob { denoise: true, denoise_settings: opaque_only, ..Default::default() }).unwrap(),
serde_json::to_value(GpuJob { denoise: true, denoise_settings: opaque_only, ..Default::default() }).unwrap(),
] {
assert_eq!(wire["denoise_settings"]["optical_filter"], false);
}
let filter = DenoiseSettings { passes: 2, strength: 0.35, firefly_suppression: false, optical_filter: false };
let cpu = CpuJob { threads: 4, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
let gpu = GpuJob { samples: 128, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
let cpu_reopened: CpuJob = serde_json::from_value(serde_json::to_value(&cpu).unwrap()).unwrap();
let gpu_reopened: GpuJob = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
assert_eq!(cpu_reopened, cpu);
assert_eq!(gpu_reopened, gpu);
let cpu_runtime = cpu_reopened.to_runtime(-1.0).unwrap();
let gpu_runtime = gpu_reopened.to_runtime(-1.0).unwrap();
assert_eq!((cpu_runtime.threads, cpu_runtime.supersampling), (4, 2));
assert_eq!((gpu_runtime.samples, gpu_runtime.supersampling), (128, 2));
assert_eq!(cpu_runtime.denoise_settings, filter);
assert_eq!(gpu_runtime.denoise_settings, filter);
assert!(!cpu_runtime.denoise_settings.optical_filter && !gpu_runtime.denoise_settings.optical_filter);
assert!(cpu_runtime.denoise_diagnostics && gpu_runtime.denoise_diagnostics);
assert!(CpuJob { threads: 65, ..cpu.clone() }.validate(0.0).is_err());
assert!(GpuJob { supersampling: 3, ..gpu.clone() }.validate(0.0).is_err());
assert!(CpuJob { denoise_settings: DenoiseSettings { strength: f64::NAN, ..filter }, ..cpu }.validate(0.0).is_err());
assert!(GpuJob { denoise_settings: DenoiseSettings { passes: 4, ..filter }, ..gpu }.validate(0.0).is_err());
}
+46 -9
View File
@@ -147,8 +147,8 @@ impl OpticalBindings {
self.0.iter()
}
pub fn validate(&self) -> Result<()> {
if self.0.len() > 32 || self.0.capacity() > 32 {
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
if self.0.len() > solidcraft_render::job::MAX_OPTICAL_TARGETS || self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
}
let mut seen = BTreeSet::new();
for binding in &self.0 {
@@ -164,7 +164,7 @@ impl OpticalBindings {
}
pub fn validate_for_cpu_path(&self, path: bool) -> Result<()> {
self.validate()?;
if self.len() > 8 {
if !path && self.len() > 8 {
return Err(DocError::Invalid("CPU supports at most eight certified optical targets".into()));
}
for binding in self.iter() {
@@ -183,11 +183,11 @@ impl OpticalBindings {
}
*old = binding;
} else {
if self.0.len() >= 32 {
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
if self.0.len() >= solidcraft_render::job::MAX_OPTICAL_TARGETS {
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
}
self.0.try_reserve_exact(1).map_err(|_| DocError::Invalid("optical binding reservation failed".into()))?;
if self.0.capacity() > 32 {
if self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
return Err(DocError::Invalid("optical capacity exceeds its bound".into()));
}
self.0.push(binding);
@@ -202,7 +202,7 @@ impl OpticalBindings {
}
impl<'de> Deserialize<'de> for OpticalBindings {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let result = Self(bounded_sequence::<D, SavedOptics, 32>(d)?);
let result = Self(bounded_sequence::<D, SavedOptics, { solidcraft_render::job::MAX_OPTICAL_TARGETS }>(d)?);
result.validate().map_err(serde::de::Error::custom)?;
Ok(result)
}
@@ -212,6 +212,25 @@ impl<'de> Deserialize<'de> for OpticalBindings {
mod advanced_model_contract_tests {
use super::*;
use solidcraft_render::glass_model::{Dispersion, GlassModel, ThinFilm};
#[test]
fn portable_optics_keep_128_targets_and_legacy_cpu_keeps_its_explicit_limit() {
let mut bindings = OpticalBindings::default();
for feature in 1..=solidcraft_render::job::MAX_OPTICAL_TARGETS {
let binding = SavedOptics {
target: RenderBodyTarget { definition: "OPUS".into(), feature: feature as u64, component: 0, occurrence_path: Vec::new() },
optics: OpticalMaterial::default(),
};
bindings.set(binding, false).unwrap();
}
bindings.validate_for_cpu_path(true).unwrap();
assert!(bindings.validate_for_cpu().is_err());
let raw = serde_json::to_value(&bindings).unwrap();
assert_eq!(serde_json::from_value::<OpticalBindings>(raw.clone()).unwrap(), bindings);
let mut too_many = raw.as_array().unwrap().clone();
too_many.push(too_many[0].clone());
assert!(serde_json::from_value::<OpticalBindings>(serde_json::Value::Array(too_many)).is_err());
}
#[test]
fn advanced_optics_keep_every_authored_number_through_saved_and_cpu_lowering() {
let model = GlassModel {
@@ -238,14 +257,32 @@ mod advanced_model_contract_tests {
#[test]
fn caustic_quality_is_portable_between_both_saved_jobs_and_runtime_requests() {
use crate::render_job::{CpuJob, GpuJob};
let caustics = Some(solidcraft_render::job::CausticOptions { photons: 8192, radius_mm: 0.4, max_depth: 12, seed: 381 });
let cpu = CpuJob { caustics, path: Some(Default::default()), progressive: Some(Default::default()), ..Default::default() };
let caustics = Some(solidcraft_render::job::CausticOptions {
photons: 8192,
radius_mm: 0.4,
max_depth: 12,
seed: 381,
area_lights: false,
environment: false,
guided_emission: true,
refresh_per_pass: true,
power_weighted_sources: true,
});
let cpu = CpuJob {
caustics,
environment_importance_sampling: true,
path: Some(Default::default()),
progressive: Some(Default::default()),
..Default::default()
};
let gpu = GpuJob { caustics, ..Default::default() };
assert_eq!(cpu.to_runtime(0.0).unwrap().caustics, caustics);
assert!(cpu.to_runtime(0.0).unwrap().environment_importance_sampling);
assert_eq!(gpu.to_runtime(0.0).unwrap().caustics, caustics);
assert_eq!(serde_json::from_value::<CpuJob>(serde_json::to_value(&cpu).unwrap()).unwrap(), cpu);
assert_eq!(serde_json::from_value::<GpuJob>(serde_json::to_value(&gpu).unwrap()).unwrap(), gpu);
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("caustics").is_none());
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("environment_importance_sampling").is_none());
assert!(serde_json::to_value(GpuJob::default()).unwrap().get("caustics").is_none());
}
}
+60 -10
View File
@@ -212,15 +212,27 @@ impl RenderSetup {
));
}
job.validate(&self.camera, self.exposure_ev)?;
if let RenderJob::GpuPath { options, .. } = job {
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
.map_err(|e| DocError::Invalid(e.to_string()))?;
}
if let RenderJob::CpuRaytrace { options, .. } = job {
if options.denoise {
solidcraft_render::job::validate_cpu_denoise_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
// Existing native-size Workstation intent remains readable. X2
// uses the bounded 8K output envelope and pays for its internal grid.
let (trace_width, trace_height) = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
(self.width, self.height)
} else {
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
.map_err(|e| DocError::Invalid(e.to_string()))?
};
if options.denoise || options.denoise_diagnostics {
solidcraft_render::job::validate_cpu_denoise_image(trace_width, trace_height).map_err(|e| DocError::Invalid(e.to_string()))?;
}
options
.budget
.validate_image(
self.width,
self.height,
trace_width,
trace_height,
solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
.map_err(|e| DocError::Invalid(e.to_string()))?,
)
@@ -670,7 +682,7 @@ mod tests {
gpu(3840, 2160).validate().unwrap();
gpu(3840, 2160).validate_for_gpu().unwrap();
let message = gpu(8193, 8193).validate().unwrap_err().to_string();
assert!(message.contains("(8192 pixels per side, 16777216 pixels in all)"), "{message}");
assert!(message.contains("(8192 pixels per side, 33554432 pixels in all)"), "{message}");
assert!(!message.contains("CPU resource profile"), "{message}");
}
@@ -883,7 +895,7 @@ mod tests {
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-portable.hdr"}}}}),
json!({"job":{"method":"gpu_path","options":{"exposure_ev":0}}}),
json!({"job":{"method":"gpu_path","options":{"samples":0}}}),
json!({"job":{"method":"gpu_path","options":{"max_depth":17}}}),
json!({"job":{"method":"gpu_path","options":{"max_depth":65}}}),
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"sky","intensity":101}}}}),
json!({"job":{"method":"gpu_path","options":{"lighting":{"environment":[-1,0,0]}}}}),
json!({"camera":Camera { fov: 0.0, ..Default::default() },"job":{"method":"gpu_path","options":{"lens":{"aperture_radius_mm":1,"focus_distance_mm":10}}}}),
@@ -1021,7 +1033,7 @@ mod cpu_budget_tests {
let old = CpuJob::default();
assert!(old.path.is_none());
assert!(serde_json::to_value(&old).unwrap().get("path").is_none());
for depth in [1, 8, 16] {
for depth in [1, 8, 16, 64] {
let setup:RenderSetup=serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"path":{"max_depth":depth},"progressive":{"samples_per_pixel":7,"seed":9}}}})).unwrap();
setup.validate().unwrap();
let mut document = crate::Document::default();
@@ -1032,7 +1044,7 @@ mod cpu_budget_tests {
}
for raw in [
json!({"path":{"max_depth":0},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":17},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":65},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":8}}),
json!({"path":{"max_depth":8},"progressive":{"samples_per_pixel":7,"seed":9},"shadows":false}),
] {
@@ -1073,7 +1085,7 @@ fn cpu_denoise_saved_flag_roundtrips_offline_and_keeps_false_omitted() {
setup.validate().unwrap();
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
options.denoise = true;
assert!(setup.validate().unwrap_err().to_string().contains("CPU denoising"));
assert_eq!(setup.validate().unwrap_err(), DocError::Invalid("LimitExceeded: output requires 1..8192 sides and at most 33554432 pixels".into()),);
}
#[cfg(test)]
@@ -1220,7 +1232,9 @@ mod cpu_adaptive_saved_tests {
serde_json::from_value::<RenderSetup>(cap).unwrap().validate().is_err(),
"16 pilots cannot be hidden outside Compact's 64-sample cap"
);
for (budget, maximum) in [(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, 4096)] {
for (budget, maximum) in
[(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, solidcraft_render::job::MAX_CPU_SAMPLES_PER_PIXEL)]
{
let mut runtime = runtime.clone();
runtime.budget = budget;
runtime.progressive = Some(CpuProgressiveOptions { samples_per_pixel: maximum - 16, seed: 81 });
@@ -1385,3 +1399,39 @@ mod cpu_celestial_saved_tests {
assert!(defaults.get("sun").is_none() && defaults.get("sky").is_none());
}
}
#[test]
fn render_quality_saved_intent_preserves_output_size_and_validates_the_internal_grid() {
let gpu: RenderSetup = serde_json::from_value(serde_json::json!({
"width":7680,"height":4320,"job":{"method":"gpu_path","options":{
"samples":128,"supersampling":2,"denoise":true,"denoise_diagnostics":true,
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false}
}}
}))
.unwrap();
gpu.validate().unwrap();
let reopened: RenderSetup = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
assert_eq!(reopened, gpu);
assert_eq!((reopened.width, reopened.height), (7680, 4320));
let mut cpu: RenderSetup = serde_json::from_value(serde_json::json!({
"width":7680,"height":4320,"job":{"method":"cpu_raytrace","options":{
"threads":4,"budget":"workstation","progressive":{"samples_per_pixel":128},"supersampling":2
}}
}))
.unwrap();
cpu.validate().unwrap();
assert_eq!((cpu.width, cpu.height), (7680, 4320));
cpu.width = 1024;
cpu.height = 1024;
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
options.budget = solidcraft_render::job::CpuBudget::Compact;
options.progressive.as_mut().unwrap().samples_per_pixel = 8;
assert!(cpu.validate().is_err(), "X2 must charge four million internal pixels to Compact's progressive limit");
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
options.supersampling = 1;
cpu.validate().unwrap();
let mut invalid = gpu;
invalid.width = 8193;
invalid.height = 1;
assert!(invalid.validate().is_err());
}
@@ -0,0 +1,231 @@
//! Prepare an explicitly shaded OPUS copy and physical CPU/GPU reference setups.
//! No render is started. Geometry and the original OPUS Master setup are retained.
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_engine::{
Session,
doc::{Document, FeatureKind, MeshData, render_settings::RenderSetup},
};
use solidcraft_geom::prelude::*;
use std::{error::Error, fs, path::PathBuf, sync::Arc};
fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
if condition { Ok(()) } else { Err(message.into()) }
}
const CURVED: &[&str] = &[
"A3 - continuous sixteen facet freeform glass knot",
"A3 - six lobe quilted aspheric witness optic",
"Glass - smoked rounded toroid",
"Glass - clear calibration sphere",
"O2 - Lens 1 - Biconvex crown",
"O2 - Lens 2 - Biconcave flint",
"O2 - Lens 3 - Thick meniscus",
"O2 - Lens 4 - Plano convex magnifier",
"O2 - Lens 5 - Aspheric correction optic",
"R2 - clear chest reactor lens",
"R2 - near clear glass eye",
"R2 - far clear glass eye",
"S2 - amber solid ellipsoid",
"S2 - cobalt fluted solid decanter",
"R2 - thorax ivory swept breastplates",
"S2 - glass gallery turned wood pedestal -217",
"S2 - glass gallery turned wood pedestal 211",
];
fn scene_counts(scene: &Value) -> Result<(usize, u64), Box<dyn Error>> {
let bodies = scene["bodies"].as_array().ok_or("missing scene bodies")?;
let triangles = bodies
.iter()
.try_fold(0u64, |sum, body| sum.checked_add(body["triangles"].as_u64().ok_or("missing triangle count")?).ok_or("triangle sum overflow"))?;
Ok((bodies.len(), triangles))
}
fn physical(master: &RenderSetup, cpu: bool, pilot: bool) -> Result<Value, Box<dyn Error>> {
let mut setup = serde_json::to_value(master)?;
let mut optics = setup["optics"].as_array().ok_or("missing optics")?.clone();
for binding in &mut optics {
binding["optics"]["shadow_transmission"] = json!(false);
}
setup["optics"] = json!(optics);
setup["mode"] = json!("final");
setup["width"] = json!(if pilot { 320 } else { 3840 });
setup["height"] = json!(if pilot { 180 } else { 2160 });
let samples = if pilot { 8u32 } else { 4096u32 };
let mut options = setup["job"]["options"].clone();
options["texture_filtering"] = json!(true);
options["linear_exr"] = json!(true);
options["max_depth"] = json!(64);
options["samples"] = json!(samples);
options["nested_media"] = json!(true);
options["denoise"] = json!(false);
if cpu {
let lighting = &options["lighting"];
let area_lights = lighting["area_lights"]
.as_array()
.ok_or("missing area lights")?
.iter()
.map(|a| {
let radiance: [f64; 3] = serde_json::from_value(a["radiance"].clone())?;
let intensity = radiance.into_iter().fold(0.0, f64::max);
let color = radiance.map(|v| if intensity > 0.0 { v / intensity } else { 0.0 });
// GPU stores half-edges; the CPU rectangle contract takes full sides.
let u: [f64; 3] = serde_json::from_value(a["u"].clone())?;
let v: [f64; 3] = serde_json::from_value(a["v"].clone())?;
Ok(json!({"center_mm":a["center"],"edge_u_mm":u.map(|x|x*2.0),"edge_v_mm":v.map(|x|x*2.0),"color":color,"intensity":intensity}))
})
.collect::<Result<Vec<Value>, serde_json::Error>>()?;
let cpu_options = json!({
"budget":"workstation", "texture_filtering":true,
"progressive":{"samples_per_pixel":samples,"seed":4619},
"path":{"max_depth":64}, "primary_samples":1, "shadows":true,
"lights":[], "area_lights":area_lights,"area_samples":1,
"punctual_lights":lighting["punctual_lights"],
"environment":{"color":lighting["environment"],"intensity":1.0},
"aging":options["aging"], "denoise":false,
"output":{"tone_map":options["tone_map"],"linear_exr":true}
});
setup["job"] = json!({"method":"cpu_raytrace","transparent_background":false,"options":cpu_options});
} else {
setup["job"]["options"] = options;
}
let checked: RenderSetup = serde_json::from_value(setup.clone())?;
checked.validate()?;
require(checked.camera == master.camera, "reference camera differs from Master")?;
require(checked.surfaces == master.surfaces && checked.coats == master.coats, "reference lost surface maps or coats")?;
Ok(setup)
}
fn crease_audit(mesh: &MeshData) -> Result<Value, Box<dyn Error>> {
let positions: Vec<Vec3> = mesh.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect();
let triangles = mesh.triangles.as_chunks::<3>().0;
let mut weld = std::collections::HashMap::new();
let mut canonical = Vec::with_capacity(positions.len());
for p in &positions {
let next = weld.len();
canonical.push(*weld.entry([(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]).or_insert(next));
}
let mut edges = std::collections::HashMap::<(usize, usize), (usize, Option<usize>, usize)>::new();
let mut face_normals = Vec::with_capacity(triangles.len());
for (i, t) in triangles.iter().enumerate() {
let [a, b, c] = t.map(|k| positions.get(k as usize).copied());
let (Some(a), Some(b), Some(c)) = (a, b, c) else { return Err("audit index missing".into()) };
face_normals.push((b - a).cross(c - a).normalized().ok_or("audit triangle degenerate")?);
for k in 0..3 {
let (a, b) = (canonical[t[k] as usize], canonical[t[(k + 1) % 3] as usize]);
edges
.entry((a.min(b), a.max(b)))
.and_modify(|e| {
e.1 = Some(i * 3 + k);
e.2 += 1;
})
.or_insert((i * 3 + k, None, 1));
}
}
let normal = |corner: usize| -> Result<Vec3, Box<dyn Error>> {
let n = mesh.corner_normals.get(corner * 3..corner * 3 + 3).ok_or("audit normal missing")?;
Ok(Vec3::new(n[0], n[1], n[2]))
};
let mut bins = [0u64; 7];
let mut sharp = 0u64;
let mut sharp_discontinuous = 0u64;
let mut boundary_or_nonmanifold = 0u64;
for (_, (a, b, count)) in edges {
let Some(b) = b.filter(|_| count == 2) else {
boundary_or_nonmanifold += 1;
continue;
};
let angle = face_normals[a / 3].dot(face_normals[b / 3]).clamp(-1.0, 1.0).acos().to_degrees();
let bin = [1.0, 5.0, 10.0, 22.5, 45.0, 90.0].iter().position(|&max| angle <= max).unwrap_or(6);
bins[bin] += 1;
if angle > 10.0 + 1e-9 {
sharp += 1;
let (ak, bk) = (a % 3, b % 3);
let same_a = normal(a)?.dist(normal((b / 3) * 3 + (bk + 1) % 3)?) <= 1e-8;
let same_b = normal((a / 3) * 3 + (ak + 1) % 3)?.dist(normal(b)?) <= 1e-8;
if !same_a || !same_b {
sharp_discontinuous += 1;
}
}
}
Ok(json!({"manifold_edge_angle_bins_degrees":[[0,1],[1,5],[5,10],[10,22.5],[22.5,45],[45,90],[90,180]],
"manifold_edge_counts":bins,"geometric_sharp_edges_above_10_degrees_before":sharp,
"geometric_sharp_edges_above_10_degrees_after":sharp,"sharp_edges_with_shading_discontinuity":sharp_discontinuous,
"boundary_or_nonmanifold_edges":boundary_or_nonmanifold}))
}
fn main() -> Result<(), Box<dyn Error>> {
let mut args = std::env::args_os().skip(1);
let baseline = PathBuf::from(args.next().ok_or("usage: opus_quality_fixture BASELINE.sc NEW_OUTPUT_DIRECTORY")?);
let output = PathBuf::from(args.next().ok_or("missing new output directory")?);
require(args.next().is_none(), "unexpected argument")?;
fs::create_dir(&output)?;
let original = fs::read(&baseline)?;
let mut doc: Document = solidcraft_io::read_design(&original)?;
let master = doc.render.setups.get("OPUS Master").cloned().ok_or("missing OPUS Master")?;
require(master.surfaces.len() == 104 && master.optics.len() == 30 && master.coats.len() == 6, "fixture bindings differ from OPUS baseline")?;
require(doc.components.len() == 82 && doc.assembly.joints.len() == 81 && doc.assembly.links.len() == 15, "fixture assembly counts differ")?;
let baseline_doc = doc.clone();
let mut baseline_session = Session::new(doc.clone());
let baseline_scene = baseline_session.execute("render.scene", &json!({}))?;
require(scene_counts(&baseline_scene)? == (511, 729874), "baseline scene geometry counts differ")?;
let mut edits = Vec::new();
for feature in &mut doc.features {
if let FeatureKind::MeshImport { meshes, .. } = &mut feature.kind {
for mesh in meshes {
if CURVED.contains(&mesh.name.as_str()) {
let positions = mesh.positions.clone();
let triangles = mesh.triangles.clone();
mesh.generate_crease_normals(10.0)?;
require(
Arc::ptr_eq(&positions, &mesh.positions) && Arc::ptr_eq(&triangles, &mesh.triangles),
"normal generation changed geometry arrays",
)?;
edits.push(json!({"feature":feature.id,"body":mesh.name,"crease_angle_degrees":10.0,"triangles":mesh.triangles.len()/3,"normal_values":mesh.corner_normals.len(),"crease_audit":crease_audit(mesh)?}));
}
}
}
}
require(edits.len() == CURVED.len(), "a curated normal target is missing or duplicated")?;
doc.name = format!("{} — curated 10 degree shading reference", doc.name);
let mut session = Session::new(doc);
let mut setup_receipts = Vec::new();
for (cpu, pilot, name) in [
(true, false, "OPUS Physical CPU 4K"),
(false, false, "OPUS Physical GPU 4K"),
(true, true, "OPUS Physical CPU Pilot"),
(false, true, "OPUS Physical GPU Pilot"),
] {
let setup = physical(&master, cpu, pilot)?;
setup_receipts.push(session.execute("render.settings.set", &json!({"name":name,"set":setup}))?);
}
require(session.doc.render.setups.get("OPUS Master") == Some(&master), "original Master setup changed")?;
let scene = session.execute("render.scene", &json!({}))?;
require(scene == baseline_scene, "shading changed captured geometry, names, bounds or stable targets")?;
require(
session.doc.components == baseline_doc.components
&& session.doc.occurrences == baseline_doc.occurrences
&& session.doc.assembly == baseline_doc.assembly,
"assembly, pose or occurrence metadata changed",
)?;
let copy_path = output.join("OPUS-04-physical-reference.sc");
let copy = solidcraft_io::write_design_container(&session.doc)?;
fs::write(&copy_path, &copy)?;
let reopened = solidcraft_io::read_design(&copy)?;
require(reopened == *session.doc, "copy changed on cold reopen")?;
fs::write(output.join("baseline-scene.json"), serde_json::to_vec_pretty(&baseline_scene)?)?;
fs::write(output.join("reference-setups.json"), serde_json::to_vec_pretty(&setup_receipts)?)?;
let report = json!({
"baseline":baseline,"reference":copy_path,"baseline_bytes":original.len(),"reference_bytes":copy.len(),
"geometry":{"bodies":511,"triangles":729874,"unchanged":true},
"assembly":{"components":82,"joints":81,"couplings":15,"unchanged":true},
"bindings_per_reference":{"surfaces":104,"optics":30,"coats":6,"aging":7,"area_lights":4,"punctual_lights":7},
"curated_normal_edits":edits,"master_unchanged":true,"camera_unchanged":true,
"assembly_and_saved_pose_unchanged":true,"recorded_motion_step15_applied":false,
"reference_shadow_transmission":false,"render_started":false,
"qualification":"fixture preparation and public settings/scene commands only; no transport convergence claim"
});
fs::write(output.join("fixture-manifest.json"), serde_json::to_vec_pretty(&report)?)?;
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
+3 -6
View File
@@ -421,13 +421,10 @@ fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits
out
}
/// A CSV field, always quoted.
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\""))
}
/// The bill of materials as CSV: fixed columns, every field quoted.
/// The bill of materials as CSV: fixed columns, every field quoted, and a field a spreadsheet
/// would run as a formula written as text.
fn csv(rows: &[Value]) -> String {
let quote = solidcraft_doc::csv::quoted;
let mut out = ["item", "part_number", "description", "revision", "count", "definition"].map(quote).join(",");
out.push('\n');
let lines = rows.iter().flat_map(|r| std::iter::once(r).chain(r["children"].as_array().into_iter().flatten()));
+14
View File
@@ -201,6 +201,20 @@ fn csv_quotes_every_field_and_json_writes_the_rows() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A field that a spreadsheet would run as a formula gets a `'` first, so it opens as text
/// (#12728). A count stays a number.
#[test]
fn a_csv_field_a_spreadsheet_would_run_opens_as_text() {
let dir = scratch("formula");
let mut s = assembly();
run(&mut s, "component.metadata.set", json!({"component": "Plate", "part_number": "=1+1", "description": "-5 mm shim", "revision": "@A"}));
let csv = dir.join("bom.csv");
run(&mut s, "assembly.bom", json!({"path": csv}));
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.contains("\n\"1\",\"'=1+1\",\"'-5 mm shim\",\"'@A\",\"3\",\"Plate\"\n"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn standard_parts_carry_a_part_number_that_follows_a_resize() {
let mut s = Session::default();
+16
View File
@@ -30,6 +30,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ASSISTANT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::AssistantResult>)
.params("Show or hide Assistant in the app. Headless calls are refused without changing the design"),
CommandSpec::new("ui.palette", "Search Commands", crate::checked!("ui.palette", palette))
.icon("zoom")
.key("S")
.noundo()
.doc_only()
.contract(&PALETTE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::PaletteResult>)
.params("Open command search in the app. Headless calls are refused without changing the design"),
CommandSpec::new("edit.undo", "Undo", crate::checked!("edit.undo", undo))
.icon("undo")
.key("Ctrl+Z")
@@ -257,6 +265,14 @@ fn assistant(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.assistant shows or hides Assistant in the app; no assistant panel exists in a headless session".into()))
}
const PALETTE_CONTRACT: crate::schema::TypedContract<crate::schema::edit::PaletteParams> = crate::schema::TypedContract::new();
/// Command search (#12190) is the app's: it opens in `SolveApp::start`, and its key can be moved
/// in Preferences › Shortcuts like any other command's.
fn palette(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.palette opens command search in the app; there is no search box in a headless session".into()))
}
const REPEAT_CONTRACT: crate::schema::TypedContract<crate::schema::edit::RepeatParams> = crate::schema::TypedContract::new();
/// Repeat Last Command (#7077) starts a tool, which only the app has: it starts the last one in
+6
View File
@@ -1687,6 +1687,12 @@ fn assistant_toggle_is_refused_headlessly_without_changing_the_document() {
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.assistant").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("Ctrl+Shift+A"), "", false));
// Command search likewise (#12190): S is its default key, not a reserved one.
let error = s.execute("ui.palette", &json!({})).unwrap_err().to_string();
assert!(error.contains("headless session"), "{error}");
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.palette").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("S"), "", false));
}
#[test]
@@ -261,9 +261,9 @@ fn an_older_two_sided_extrude_with_a_negative_distance_keeps_its_sides() {
let mut base = fixture(json!({"direction":"positive","distance":"10 mm","distance2":"10 mm"}));
run(&mut base, "parameters.add", json!({"name":"width","expression":"20 mm"}));
let mut saved: Value = serde_json::from_str(&base.doc.to_json()).unwrap();
// As the last release saved it: the format before this build's pending changes.
let version: u32 = saved["format"].as_str().unwrap().trim_start_matches("solidcraft/").parse().unwrap();
saved["format"] = json!(format!("solidcraft/{}", version - 1));
// Format 50 predates the v51 second-side migration. A moving current-1 tag
// can already include that migration after unrelated format changes.
saved["format"] = json!("solidcraft/50");
saved.as_object_mut().unwrap().remove("format_pending");
for (distance, lo, hi) in [("-width", -20.0, 10.0), ("-0 mm", -10.0, 0.0), ("-10 mm", -10.0, 10.0)] {
let mut file = saved.clone();
+6 -3
View File
@@ -15,8 +15,8 @@ use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, s
use crate::schema::features::SweepParams;
use crate::schema::features::{
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
SphereParams, TorusParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams,
ScaleParams, SphereParams, TorusParams,
};
use crate::schema::split::{SplitBodyParams, SplitFaceParams};
use crate::schema::{TypedContract, output_schema_for};
@@ -24,6 +24,7 @@ use crate::{EngineError, Result, Session};
const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
const EXTRUDE_CONTRACT: TypedContract<ExtrudeParams> = TypedContract::new();
const REVOLVE_CONTRACT: TypedContract<RevolveParams> = TypedContract::new();
const CYLINDER_CONTRACT: TypedContract<CylinderParams> = TypedContract::new();
const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
@@ -48,9 +49,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXTRUDE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (how a symmetric distance is read; default total_distance, split evenly around the start plane; legacy_per_side: the distance on each side, as older symmetric extrudes keep); distance2?: expr (Two Sides: a second distance on the side direction leaves free, the first side may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it; a negative distance only turns its own side over); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
CommandSpec::new("solid.revolve", "Revolve", crate::checked!("solid.revolve", revolve))
.at("SOLID", "CREATE")
.icon("revolve")
.contract(&REVOLVE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
.at("SOLID", "CREATE")
+78 -28
View File
@@ -13,6 +13,7 @@ use super::features::{
add_feature, axis_dir_param, check_expr, feature_sketch, length_operands, plane_param, point_param, profiles, typed_length_unit,
};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::schema::align::AlignParams;
use crate::schema::features::FeatureResult;
use crate::schema::features_more::{BoundaryFillParams, RemoveParams, RibParams};
use crate::schema::{TypedContract, output_schema_for};
@@ -20,6 +21,7 @@ use crate::{Result, Session};
/// Rib and Web take the same keys.
const RIB_CONTRACT: TypedContract<RibParams> = TypedContract::new();
const ALIGN_CONTRACT: TypedContract<AlignParams> = TypedContract::new();
const REMOVE_CONTRACT: TypedContract<RemoveParams> = TypedContract::new();
const BOUNDARY_FILL_CONTRACT: TypedContract<BoundaryFillParams> = TypedContract::new();
@@ -58,9 +60,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("replace_face")
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", align)
CommandSpec::new("solid.align", "Align", crate::checked!("solid.align", align))
.at("SOLID", "MODIFY")
.icon("align")
.contract(&ALIGN_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); from_frame / to_frame: a frame found again whenever the design changes, so the bodies follow it, in place of that side's point, face or plane: a joint origin as joint.create takes one ({face|circle|edge|point: [x,y,z]}, a face's name, a named joint origin) or {sketch, point|curve} (a sketch point, or a circle's, arc's or ellipse's centre, z along the sketch's normal), on the bodies' own component; faces, planes and frames also turn to meet; flip?: bool; axes?: [x, y, z] (point-to-point only: which world axes the offset moves along; default all three, at least one true); angle?: expr (a turn about the target's direction after the move)"),
CommandSpec::new("solid.remove", "Remove", crate::checked!("solid.remove", remove))
.at("SOLID", "MODIFY")
@@ -421,21 +425,58 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::ReplaceFace { faces: faces.into_iter().map(FacePick).collect(), target, body: body.map(BodyName) })
}
/// What `solid.align` says when `axes` is not three booleans, or when none is true.
pub(crate) const AXES_SHAPE: &str = "`axes` must be [x, y, z] booleans";
pub(crate) const AXES_NONE: &str = "`axes` must enable at least one of X, Y and Z";
/// `axes` as `solid.align` reads it: three booleans, one per world axis, at least one of them
/// true; the message is the handler's.
pub(crate) fn axes_mask(v: &Value) -> std::result::Result<[bool; 3], &'static str> {
let a = v.as_array().filter(|a| a.len() == 3).ok_or(AXES_SHAPE)?;
let mut mask = [false; 3];
for (slot, v) in mask.iter_mut().zip(a) {
*slot = v.as_bool().ok_or(AXES_SHAPE)?;
}
if !mask.iter().any(|on| *on) {
return Err(AXES_NONE);
}
Ok(mask)
}
/// `axes`: three booleans, one per world axis, for a point-to-point Align.
/// `None` when the call does not restrict the move.
fn axes_param(p: &Value, cmd: &str) -> Result<Option<[bool; 3]>> {
let Some(v) = p.get("axes").filter(|v| !v.is_null()) else { return Ok(None) };
let a = v.as_array().filter(|a| a.len() == 3).ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?;
let mut mask = [false; 3];
for (slot, v) in mask.iter_mut().zip(a) {
*slot = v.as_bool().ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?;
}
if !mask.iter().any(|on| *on) {
return Err(bad(cmd, "`axes` must enable at least one of X, Y and Z"));
}
let mask = axes_mask(v).map_err(|why| bad(cmd, why))?;
Ok((mask != [true; 3]).then_some(mask))
}
/// An Align side given as a point (`from` or `to`), as the handler reads it.
pub(crate) enum SidePoint {
/// The text `origin` (any case): the world origin.
Origin,
/// `{snap?, at}`: the nearest `snap` (vertex unless the object says another) to `at`.
Snap { at: Vec3, kind: String },
/// `[x, y, z]`.
Point(Vec3),
}
/// The side `pk` (`from` or `to`) as a point, with the handler's message when it is none of the
/// forms; `fk` is the face key beside it (`from_face`).
pub(crate) fn side_point(v: Option<&Value>, pk: &str, fk: &str) -> std::result::Result<SidePoint, String> {
// The origin point.
if v.and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok(SidePoint::Origin);
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = v.filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| format!("`{pk}.at` must be [x, y, z]"))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex").to_string();
return Ok(SidePoint::Snap { at, kind });
}
v.and_then(vec3).map(SidePoint::Point).ok_or_else(|| format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)"))
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.align";
let bodies = string_list(cmd, p, "bodies")?;
@@ -492,20 +533,13 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let n = super::face::face_normal_world(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// The origin point.
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
match side_point(p.get(pk), pk, fk).map_err(|why| bad(cmd, why))? {
SidePoint::Origin => Ok((Vec3::ZERO, None)),
SidePoint::Snap { at, kind } => {
snap_point(&*s.world_state()?, &kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")))
}
SidePoint::Point(v) => Ok((v, None)),
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(&*s.world_state()?, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
};
let pick = |s: &Session, pk: &str, fk: &str, held: bool| -> Result<(Vec3, Option<Vec3>)> {
let (at, n) = world_pick(s, pk, fk, held)?;
@@ -568,7 +602,24 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The longest sketch point or curve id an Align frame takes.
const MAX_SKETCH_ID: usize = 256;
pub(crate) const MAX_SKETCH_ID: usize = 256;
/// The point or curve of an Align `{sketch, point|curve}` frame: an id of 1 to
/// [`MAX_SKETCH_ID`] characters. `None` when the frame gives both, neither or an id that is not
/// one.
pub(crate) enum SketchFrameId {
Point(String),
Curve(String),
}
pub(crate) fn sketch_frame_id(v: &Value) -> Option<SketchFrameId> {
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
match (id("point"), id("curve")) {
(Some(point), None) => Some(SketchFrameId::Point(point)),
(None, Some(curve)) => Some(SketchFrameId::Curve(curve)),
_ => None,
}
}
/// An Align side's frame (#6257), `<key>_frame`: a joint origin as `joint.create` takes one, or a
/// sketch point or the centre of a sketch circle, arc or ellipse (`{sketch, point|curve}`), on the
@@ -585,11 +636,10 @@ fn align_frame(s: &Session, p: &Value, key: &str, bodies: &[String]) -> Result<O
let (frame, on) = match v.get("sketch") {
Some(sk) => {
let sketch = super::features::sketch_id(s, Some(sk), cmd, &fk)?;
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
let frame = match (id("point"), id("curve")) {
(Some(point), None) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
(None, Some(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
_ => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
let frame = match sketch_frame_id(v) {
Some(SketchFrameId::Point(point)) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
Some(SketchFrameId::Curve(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
None => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
};
(frame, s.doc.feature(sketch).map_or(0, |f| f.component))
}
+200 -30
View File
@@ -23,12 +23,26 @@ use solidcraft_io::Format;
use super::CommandSpec;
use super::print_face;
use crate::params::{bad, bool_, num, str_, string_list};
use crate::schema::file::ExportResult;
use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
use crate::schema::new_design::{NewDesignParams, NewDesignResult};
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
#[cfg(not(target_arch = "wasm32"))]
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
use crate::schema::slicers::{SlicerInfo, SlicersParams, SlicersResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const INSERT_MESH_CONTRACT: TypedContract<InsertMeshParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("file.new", "New Design", crate::checked!("file.new", new_doc))
.icon("new")
.key("Ctrl+N")
.noundo()
.contract(&NEW_DESIGN_CONTRACT)
.output(output_schema_for::<NewDesignResult>)
.params("name?"),
CommandSpec::new("doc.open", "Open", open)
.icon("open")
.key("Ctrl+O")
@@ -38,10 +52,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?, group?: the folder); folder?: bool (default false; true: an import that brings more than one item into the history (its Import feature and the rigid joints holding its parts) goes into a new history folder named `Group #N`); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
CommandSpec::new("file.insert_mesh", "Insert Mesh", crate::checked!("file.insert_mesh", insert_mesh))
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
.contract(&INSERT_MESH_CONTRACT)
.output(output_schema_for::<MeshInserted>)
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made); crease_angle_degrees?: 0..180 (0 clears imported shading normals for the previous flat-shading behavior; positive angles regenerate angle-weighted normals across manifold edges below this crease angle; omitted keeps authored OBJ normals/smoothing groups, or flat shading when absent; geometry stays unchanged)"),
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
.icon("save")
.key("Ctrl+S")
@@ -102,10 +118,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXPORT_URDF_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::UrdfExportResult>)
.params("path; mesh_prefix?; effort? (default 1000); velocity? (default 10) → {path, format, files, links, joints, warnings}: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh)
CommandSpec::new("file.save_mesh", "Save As Mesh", crate::checked!("file.save_mesh", save_mesh))
.at("INSERT", "FILES")
.icon("mesh_export")
.noundo()
.contract(&SAVE_MESH_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::save_mesh::SaveMeshResult>)
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
.enabled(starts_programs)
@@ -117,8 +135,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("mesh_export")
.noundo()
.params("consent: bool (required: true only once the user has agreed to upload the model to Printables' servers; it stays there for 30 minutes and opening it needs a Printables sign-in); bodies?: [names] (default: the selected bodies); format?: 3mf|stl (default 3mf, in millimetres) → uploads the bodies to EasyPrint, Printables' slicer in the browser, and opens it in the browser. 100 MB at most. Works in the desktop app and the browser build; the CLI has no way to upload yet"),
CommandSpec::new("file.slicers", "Installed Slicers", slicers).icon("export")
CommandSpec::new("file.slicers", "Installed Slicers", crate::checked!("file.slicers", slicers))
.icon("export")
.noundo()
.contract(&SLICERS_CONTRACT)
.output(output_schema_for::<SlicersResult>)
.params("→ the slicers found on this machine: [{id, name, program, icon?}]; pass an id, a name or the program path as file.send_to_slicer's or file.save_mesh's `slicer`"),
];
@@ -127,9 +148,12 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
s.replace_design(Session::new(Document::new(name)));
// A name given here is the user's own, so Save As keeps it (#4907); "Untitled" is none.
s.user_named = !name.trim().is_empty() && name.trim() != DEFAULT_NAME;
Ok(json!({"name": name}))
let reply = NewDesignResult { name: name.to_string(), param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| bad("file.new", e.to_string()))
}
const NEW_DESIGN_CONTRACT: TypedContract<NewDesignParams> = TypedContract::new();
const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportParams> = crate::schema::TypedContract::new();
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
@@ -137,7 +161,9 @@ const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::Ex
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
const EXPORT_OBJ_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const SLICERS_CONTRACT: TypedContract<SlicersParams> = TypedContract::new();
const EXPORT_URDF_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportUrdfParams> = crate::schema::TypedContract::new();
const SAVE_MESH_CONTRACT: crate::schema::TypedContract<crate::schema::save_mesh::SaveMeshParams> = crate::schema::TypedContract::new();
fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))
@@ -273,11 +299,19 @@ fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
/// Add a MeshImport feature for a 3MF, STL, OBJ or glTF file, in a new component named after the
/// file. A glTF file's nodes become components in it, and its materials the bodies' looks; it is
/// in metres with Y up, so unless `y_up` is false its component is turned to Z up (#1505).
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>, unit: Option<(&'static str, f64)>, origin: bool) -> Result<Value> {
fn add_mesh(
s: &mut Session,
path: &str,
name: Option<&str>,
y_up: Option<bool>,
unit: Option<(&'static str, f64)>,
origin: bool,
crease_angle: Option<f64>,
) -> Result<Value> {
let err = |e: solidcraft_io::IoError| EngineError::Other(format!("{path}: {e}"));
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let gltf = solidcraft_io::is_gltf_path(path);
let (mf, plan, looks) = if gltf {
let (mut mf, plan, looks) = if gltf {
let dir = std::path::Path::new(path).parent().map(std::path::Path::to_path_buf).unwrap_or_default();
let mut load =
|name: &str| solidcraft_io::vfs::read(&dir.join(name).to_string_lossy(), solidcraft_io::MAX_GLTF_BYTES).map_err(|e| e.to_string());
@@ -289,6 +323,19 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
} else {
(solidcraft_io::mesh_import_feature_in(&bytes, path, unit.map_or(1.0, |(_, mm)| mm)).map_err(err)?, None, Vec::new())
};
if let Some(angle) = crease_angle
&& let solidcraft_doc::FeatureKind::MeshImport { meshes, .. } = &mut mf.kind
{
for mesh in meshes {
if angle == 0.0 {
// Keep the original flat display path, including its normalization
// and storage behavior, when the caller opts out of imported shading.
mesh.corner_normals = Default::default();
} else {
mesh.generate_crease_normals(angle).map_err(|e| bad("file.insert_mesh", e.to_string()))?;
}
}
}
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let cname = import::component_name(path, name);
let tree = match &plan {
@@ -310,18 +357,24 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
// After the turn, so it is the stood-up mesh that sits on Z = 0.
let moved = if origin { import::move_to_origin(s, &placed)? } else { None };
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings});
placed.report(&mut out);
if let Some(shift) = moved {
out["moved_to_origin"] = json!([shift.x, shift.y, shift.z]);
}
if let Some((name, _)) = unit {
out["units"] = json!(name);
}
if let Some(turned) = turned {
out["stand_up"] = json!(turned);
}
Ok(out)
let reply = MeshInserted {
path: path.to_string(),
feature: id,
name: fname,
bodies: bodies.clone(),
created_bodies: bodies,
warnings: mf.warnings,
component: placed.component,
component_name: placed.name.clone(),
occurrence: placed.occurrence,
subcomponents: (placed.components > 0).then_some(placed.components),
rigid_joints: (placed.components > 0).then_some(placed.joints.len()),
moved_to_origin: moved.map(|shift| [shift.x, shift.y, shift.z]),
units: unit.map(|(name, _)| name.to_string()),
stand_up: turned,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad("file.insert_mesh", e.to_string()))
}
/// Turn an import's component a quarter turn about X (the file's Y up becomes Z up), contents
@@ -376,7 +429,20 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
}
let up = mesh_up(p, path, cmd)?;
let unit = mesh_units(p, path, cmd)?;
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?)
let crease_angle = mesh_crease_angle(p.get("crease_angle_degrees")).map_err(|message| bad(cmd, message))?;
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?, crease_angle)
}
/// The shading-only crease reader, shared by the handler and its typed contract.
pub(crate) fn mesh_crease_angle(value: Option<&Value>) -> std::result::Result<Option<f64>, &'static str> {
match value {
None | Some(Value::Null) => Ok(None),
Some(value) => value
.as_f64()
.filter(|v| v.is_finite() && (0.0..=180.0).contains(v))
.map(Some)
.ok_or("crease_angle_degrees must be between 0 and 180 degrees"),
}
}
/// `origin`: whether a mesh import is moved to the origin (#12543). Off unless asked, so a mesh
@@ -469,7 +535,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let r = if solidcraft_io::is_step_path(path) {
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
} else {
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?)?
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?, None)?
};
// The design is the file: its component is the one being worked in.
if let Some(c) = r["component"].as_u64() {
@@ -853,7 +919,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
for body in &world.bodies {
let mesh = body.cached_mesh()?;
bytes =
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24);
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(84)).saturating_add(32);
triangles = triangles.saturating_add(mesh.triangles.len());
if bytes > 8 << 20 || triangles > 100_000 {
return None;
@@ -863,6 +929,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: s.doc.body_color(body),
corner_normals: mesh.triangles.iter().flatten().filter_map(|&i| mesh.normals.get(i as usize)).flat_map(|n| [n.x, n.y, n.z]).collect(),
});
}
Some(preview)
@@ -980,7 +1047,9 @@ fn export_to(
solidcraft_io::export_with_parts(&s.doc.painted(&*s.world_state()?), bodies, parts, format, &name, &s.doc.metadata, quality)?
};
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
let reply =
ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), files: Vec::new(), param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
@@ -998,9 +1067,16 @@ fn export_laid_to(
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality)?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut out = json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")});
laid.report(&mut out);
Ok(out)
let reply = SaveMeshResult {
path: path.to_string(),
bytes: bytes.len(),
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
delivery: SlicerDelivery::default(),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1219,11 +1295,16 @@ fn send_to_slicer(s: &mut Session, p: &Value) -> Result<Value> {
}
fn slicers(_: &mut Session, _: &Value) -> Result<Value> {
let list: Vec<Value> = crate::slicers::detect()
let slicers = crate::slicers::detect()
.iter()
.map(|d| json!({"id": d.id, "name": d.name, "program": d.command.first(), "icon": d.icon.as_ref().map(|i| i.path().to_string_lossy().into_owned())}))
.map(|d| SlicerInfo {
id: d.id.clone(),
name: d.name.clone(),
program: d.command.first().cloned(),
icon: d.icon.as_ref().map(|i| i.path().to_string_lossy().into_owned()),
})
.collect();
Ok(json!({ "slicers": list }))
serde_json::to_value(SlicersResult { slicers, param_warnings: Vec::new() }).map_err(|e| bad("file.slicers", e.to_string()))
}
fn recovery_dir(p: &Value, cmd: &str) -> Result<std::path::PathBuf> {
@@ -2001,6 +2082,95 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
#[test]
fn mesh_import_explicit_crease_normals_are_undoable_and_survive_cached_reopen() {
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-normals-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("fold.obj");
std::fs::write(&path, "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\nf 1 2 3\nf 2 1 4\n").unwrap();
let mut session = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
let before = session.doc.to_json();
for bad_angle in [json!(-1), json!(181), json!("smooth")] {
let result = session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":bad_angle}));
assert!(result.unwrap_err().to_string().contains("crease_angle_degrees"));
assert_eq!(session.doc.to_json(), before);
}
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":40})).unwrap();
let normals = |s: &Session| s.model.state().bodies[0].mesh().normals.clone();
let expected = normals(&session);
assert!(expected[0].y > 0.2 && expected[0].z > 0.9);
session.execute("edit.undo", &json!({})).unwrap();
assert!(session.model.state().bodies.is_empty());
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(normals(&session), expected);
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
let cached = solidcraft_io::read_design_geometry(&bytes).expect("shaded mesh retains native cache");
assert!(!cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
let saved_path = dir.join("shaded.sc");
std::fs::write(&saved_path, &bytes).unwrap();
let mut reopened = Session::default();
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
assert_eq!(normals(&reopened), expected);
let doc = solidcraft_io::read_design(&bytes).unwrap();
let cold = Session::new(doc);
assert_eq!(normals(&cold), expected);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn mesh_import_zero_crease_restores_legacy_flat_shading_and_survives_reopen() {
let dir = std::env::temp_dir().join(format!("solidcraft-obj-flat-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n";
let faces = "f 1 2 3\nf 2 1 4\n";
let reference_path = dir.join("reference.obj");
std::fs::write(&reference_path, format!("{vertices}{faces}")).unwrap();
let mut reference = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
reference.execute("file.insert_mesh", &json!({"path":reference_path})).unwrap();
let display = |s: &Session| {
let mesh = s.model.state().bodies[0].mesh();
(mesh.positions.clone(), mesh.triangles.clone(), mesh.normals.clone())
};
let expected = display(&reference);
for (name, source) in
[("groups", format!("{vertices}s 1\n{faces}")), ("authored", format!("{vertices}vn 0 1 1\nf 1//1 2//1 3//1\nf 2//1 1//1 4//1\n"))]
{
let path = dir.join(format!("{name}.obj"));
std::fs::write(&path, source).unwrap();
let mut annotated = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
annotated.execute("file.insert_mesh", &json!({"path":path})).unwrap();
assert_ne!(display(&annotated).2, expected.2, "{name} must exercise imported shading");
let mut session = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":0})).unwrap();
assert_eq!(display(&session), expected, "{name} uses the exact former flat display path");
let meshes = session
.doc
.features
.iter()
.find_map(|f| match &f.kind {
solidcraft_doc::FeatureKind::MeshImport { meshes, .. } => Some(meshes),
_ => None,
})
.unwrap();
assert!(meshes.iter().all(|mesh| mesh.corner_normals.is_empty()));
session.execute("edit.undo", &json!({})).unwrap();
assert!(session.model.state().bodies.is_empty());
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(display(&session), expected);
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
let cached = solidcraft_io::read_design_geometry(&bytes).unwrap();
assert!(cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
let saved_path = dir.join(format!("{name}.sc"));
std::fs::write(&saved_path, &bytes).unwrap();
let mut reopened = Session::default();
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
assert_eq!(display(&reopened), expected);
let cold = Session::new(solidcraft_io::read_design(&bytes).unwrap());
assert_eq!(display(&cold), expected);
}
std::fs::remove_dir_all(dir).unwrap();
}
/// #9170: a design holding a mesh body (an inserted STL) keeps its saved model and opens from
/// it. One mesh body left the cache out for the whole model, so every feature, the boxes
/// too, was evaluated again on opening.
+12 -5
View File
@@ -13,6 +13,7 @@ use solidcraft_io::Format;
use super::easyprint::{Exported, export_bodies};
use super::export_laid_to;
use crate::params::bad;
use crate::schema::save_mesh::SlicerDelivery;
use crate::slicers::{self, Slicer, TargetKind};
use crate::{EngineError, Result, Session};
@@ -120,18 +121,24 @@ fn deliver_via(
out: &mut Value,
) {
let Some(obj) = out.as_object_mut() else { return };
// What happened, as the typed value the reply's schema describes; only its one key is set.
let mut say = |delivery: SlicerDelivery| {
if let Ok(Value::Object(keys)) = serde_json::to_value(delivery) {
obj.extend(keys);
}
};
// A target that drops a file given at a cold start is started alone, and sent the file once it is ready.
if wait != Wait::No && target.waits_for_ready().is_some() && target.ready_via(busctl, slicers::PROBE_LIMIT) == Some(false) {
if let Err(e) = target.start(single_instance) {
obj.insert("slicer_error".into(), json!(e));
say(SlicerDelivery { slicer_error: Some(e), ..Default::default() });
return;
}
if wait == Wait::Return {
obj.insert("starting".into(), json!(target.name));
say(SlicerDelivery { starting: Some(target.name.clone()), ..Default::default() });
return;
}
if !slicers::wait_for_ready_via(target, busctl, limit, poll) {
obj.insert("slicer_timeout".into(), json!(target.name));
say(SlicerDelivery { slicer_timeout: Some(target.name.clone()), ..Default::default() });
return;
}
// After a start the file has to reach the instance that is now running, so the flag goes
@@ -140,8 +147,8 @@ fn deliver_via(
}
// The file is saved either way; a program that won't start is reported, not fatal.
match target.open(path, single_instance) {
Ok(()) => obj.insert("slicer".into(), json!(target.name)),
Err(e) => obj.insert("slicer_error".into(), json!(e)),
Ok(()) => say(SlicerDelivery { slicer: Some(target.name.clone()), ..Default::default() }),
Err(e) => say(SlicerDelivery { slicer_error: Some(e), ..Default::default() }),
};
}
+2
View File
@@ -46,6 +46,8 @@ pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub(crate) use features::{rail_fit_form, sweep_orientation};
pub(crate) use features_more::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, side_point, sketch_frame_id};
pub(crate) use file::mesh_crease_angle;
pub use file::open_steps::OpenSteps;
pub(crate) use inspect::color_param;
pub use join_pieces::{Addition, addition};
+1 -1
View File
@@ -913,7 +913,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
c += &format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
Value::from(r.step),
at.occurrence.replace(',', " "),
solidcraft_doc::csv::field(&at.occurrence),
o[0],
o[1],
o[2],
+19
View File
@@ -687,6 +687,25 @@ fn motion_study_plays_and_exports() {
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
/// A placement's name in a motion CSV is one field, whatever it holds (#12728). A comma is kept in
/// quotes, where it used to become a space; a quote or a line break no longer splits the row; and a
/// name that a spreadsheet would run as a formula opens as text.
#[test]
fn a_motion_csv_keeps_each_placement_name_in_its_field() {
let mut s = rail();
run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
run(&mut s, "component.rename", json!({"component": "B", "name": "=B, \"x\"\ny"}));
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-motion-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("names.csv");
run(&mut s, "motion.export", json!({"study": "Push", "samples": 2, "path": csv.to_string_lossy()}));
let text = std::fs::read_to_string(&csv).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(text.contains(",A:1,") && text.contains(",\"'=B, \"\"x\"\"\ny:1\","), "{text}");
// A header, a row for each placement at each of the two samples, and the line break in each of B's rows.
assert_eq!(text.matches('\n').count(), 1 + 2 * 2 + 2, "{text}");
}
/// Regression (#9741): `motion.study` takes degrees for angle keys, so `motion.list` must give
/// the keys back in those same units — feeding a listed study straight back to `motion.study`
/// must not turn a 90° key into 1.57°.
+21
View File
@@ -400,6 +400,27 @@ fn csv_and_json_round_trip() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A value a spreadsheet would run as a formula is exported as text, with a `'` first, and
/// imports back as it was (#12728). A plain number keeps its sign, and `;` makes a field quoted.
#[test]
fn csv_keeps_formulas_as_text_and_imports_them_back() {
let mut s = Session::default();
let rows = [("d3", "4", "plain"), ("a", "=d3", "+2 for the fit"), ("b", "=5", "'=kept"), ("c", "-5 mm", "@x"), ("e", "6", "one; two")];
for (name, expression, comment) in rows {
run(&mut s, "parameters.add", json!({"name": name, "expression": expression, "comment": comment}));
}
let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string();
for written in [",'=d3,", ",'+2 for the fit,", ",'=5,", ",''=kept,", ",'-5 mm,-5,'@x,", ",\"one; two\","] {
assert!(csv.contains(written), "{written}: {csv}");
}
let mut t = Session::default();
run(&mut t, "parameters.import", json!({"text": csv}));
for (name, _, comment) in rows {
let (before, after) = (s.doc.param(name), t.doc.param(name));
assert_eq!(after.map(|p| (p.expr.as_str(), p.comment.as_str())), before.map(|p| (p.expr.as_str(), comment)), "{name}: {csv}");
}
}
#[test]
fn csv_round_trips_comments_with_line_breaks() {
for comment in ["line one\nline two", "\r\nfirst, \"quoted\"\r\n\r\nlast\r\n", "one\rtwo"] {
+186 -9
View File
@@ -7,7 +7,10 @@
//! once per hole, each placement with its own joint.
//!
//! Part frame: the seating plane is z = 0 (the underside of a head, the bottom of a nut or
//! washer, the top of a set screw or pin); the shank runs down −z.
//! washer, the top of a set screw or pin); the shank runs down −z. A countersunk screw is the
//! exception: its head sinks into the part, so z = 0 is the top of the head, level with the face.
//! Seated at a hole, its cone lies in the hole's 90 degree countersink: pick the rim where the
//! countersink meets the bore.
use serde_json::{Value, json};
use solidcore_record::Vec3;
@@ -41,12 +44,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&INSERT_CONTRACT)
.output(output_schema_for::<InsertedPart>)
.params(
"family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
"family (socket_head_cap_screw | button_head_cap_screw | countersunk_socket_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
hex_cap_screw_inch | hex_nut_inch | washer_sae | washer_uss); size (\"M6\", \"608\" for bearings, \"1/2-13 UNC\" for inch screws and nuts, \
\"1/2\" for inch washers); length? (mm, inch parts too; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint), or a list of them (one component, \
placed and seated once per hole) | point?: [x,y,z] with direction?: [x,y,z] (no joint: the part's seating plane, under a head or \
the bottom of a nut, washer or bearing, the top of a set screw or pin, goes to point, and direction is its +z, from the shank's tip toward the head; default [0,0,1], \
the bottom of a nut, washer or bearing, the top of a set screw, pin or countersunk head, goes to point, and direction is its +z, from the shank's tip toward the head; default [0,0,1], \
head up, shank down -z); name? → component, occurrence, joint?, \
and with at: occurrences, joints (one per hole, in pick order)",
),
@@ -61,6 +64,7 @@ pub static COMMANDS: &[CommandSpec] = &[
const FAMILIES: &[(&str, &str, &str)] = &[
("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"),
("button_head_cap_screw", "Button head cap screw", "ISO 7380-1"),
("countersunk_socket_screw", "Countersunk socket screw", "ISO 10642"),
("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"),
("hex_nut", "Hex nut", "ISO 4032"),
("washer", "Plain washer", "ISO 7089"),
@@ -136,6 +140,26 @@ const BHCS: [(&str, f64, f64, f64, f64); 8] = [
const BUTTON_FLANK: f64 = 0.15;
const BUTTON_FLAT: f64 = 0.7;
/// ISO 10642: (size, theoretical head diameter dk, head height k, socket s, key engagement t).
/// dk is the diameter of the 90° cone where it meets the top plane, so k = (dk − d) / 2 at every
/// size; the standard's real rim is rounded and a little smaller (dk max 5.81 at M3), which the
/// sharp cone does not draw. t is the standard's minimum engagement depth: the 2013 edition has it
/// larger at M4, M5, M8 and M10 (1.5, 1.9, 3 and 3.6), and these are the newer edition's. M14 is
/// the standard's non-preferred size. The standard runs M2 to M20; M2 and M2.5 are left out here
/// because only one of the tables we compared lists them.
const CSK: [(&str, f64, f64, f64, f64); 10] = [
("M3", 6.72, 1.86, 2.0, 1.1),
("M4", 8.96, 2.48, 2.5, 1.4),
("M5", 11.2, 3.1, 3.0, 1.75),
("M6", 13.44, 3.72, 4.0, 2.2),
("M8", 17.92, 4.96, 5.0, 2.9),
("M10", 22.4, 6.2, 6.0, 3.5),
("M12", 26.88, 7.44, 8.0, 4.3),
("M14", 30.8, 8.4, 10.0, 4.5),
("M16", 33.6, 8.8, 10.0, 4.8),
("M20", 40.32, 10.16, 12.0, 5.6),
];
/// ISO 4014/4017 hex head: (size, across flats s, head height k).
const HEX_BOLT: [(&str, f64, f64); 14] = [
("M2", 4.0, 1.4),
@@ -396,12 +420,21 @@ enum Head {
k: f64,
flat: f64,
},
/// A countersunk head: a 90° cone from the shank, k deep, opening to dk at the top plane
/// z = 0. The screw's length is measured from that plane, head included.
Countersunk {
dk: f64,
k: f64,
},
Hex {
s: f64,
k: f64,
},
}
/// The shape of one part of a family, from its standard's table: the size and, for screws, bolts,
/// set screws and pins, a length in mm (a standard one near 2.5 d when none is given). The error
/// says which family, size or length does not exist.
fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<Shape, String> {
let need = |what: &str| format!("no {what} size `{size}` (parts.library lists them)");
let len = |d: f64| length.unwrap_or_else(|| default_length(d));
@@ -416,6 +449,15 @@ fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<S
let (_, dk, k, s, t) = BHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7380-1"))?;
Shape::Screw { d, pitch: Some(pitch), len: len(d), head: Head::Dome { dk, k, flat: BUTTON_FLAT * s }, socket: Some((s, t)) }
}
"countersunk_socket_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, dk, k, s, t) = CSK.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 10642"))?;
let len = len(d);
if len <= k {
return Err(format!("a {size} countersunk screw's length counts its head, so it must be over {k} mm (it is {len} mm)"));
}
Shape::Screw { d, pitch: Some(pitch), len, head: Head::Countersunk { dk, k }, socket: Some((s, t)) }
}
"hex_bolt" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?;
@@ -573,6 +615,12 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
revolve_domed(s, &outline, crown(a, c, flat, k), "new")?;
k
}
// One revolved outline again, but the head hangs below z = 0: the shank, the cone
// opening up to dk at the top, and the top's flat, which the socket is cut into.
Head::Countersunk { dk, k } => {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, -k), (dk / 2.0, 0.0), (0.0, 0.0)], "new")?;
0.0
}
// A hex head joined over a shank that runs up into it.
Head::Hex { s: af, k } => {
hex(s, af, 0.0, k, "new")?;
@@ -584,7 +632,12 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
hex(s, af, k, k - t, "cut")?;
}
if let Some(p) = pitch {
thread(s, d, p, Vec3::new(d / 2.0, 0.0, -len / 2.0));
// Halfway down the shank, which a countersunk head leaves shorter than the length.
let mid = match head {
Head::Countersunk { k, .. } => -(len + k) / 2.0,
_ => -len / 2.0,
};
thread(s, d, p, Vec3::new(d / 2.0, 0.0, mid));
}
}
Shape::Nut { d, pitch, s: af, m } => {
@@ -610,11 +663,14 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
Ok(())
}
/// `parts.library`: the families, or with `family` one family's sizes (the rows of its table, in
/// mm) and its standard lengths.
fn library(_s: &mut Session, p: &Value) -> Result<Value> {
let fam = |id: &str| -> PartFamily {
let mut sizes: Vec<Value> = match id {
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"button_head_cap_screw" => BHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"countersunk_socket_screw" => CSK.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"hex_bolt" => HEX_BOLT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "head_height": r.2})).collect(),
"hex_nut" => HEX_NUT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "height": r.2})).collect(),
"washer" => WASHER.iter().map(|r| json!({"size": r.0, "inner": r.1, "outer": r.2, "thickness": r.3})).collect(),
@@ -637,7 +693,9 @@ fn library(_s: &mut Session, p: &Value) -> Result<Value> {
};
let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", ""));
let lengths = match id {
"socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin" => Some(LENGTHS.to_vec()),
"socket_head_cap_screw" | "button_head_cap_screw" | "countersunk_socket_screw" | "hex_bolt" | "set_screw" | "dowel_pin" => {
Some(LENGTHS.to_vec())
}
"hex_cap_screw_inch" => Some(inch_lengths()),
_ => None,
};
@@ -687,6 +745,7 @@ fn identity(family: &str, size: &str, sh: &Shape) -> (String, String) {
let standard = match family {
"socket_head_cap_screw" => "ISO 4762",
"button_head_cap_screw" => "ISO 7380-1",
"countersunk_socket_screw" => "ISO 10642",
"hex_bolt" => "ISO 4017",
"hex_nut" => "ISO 4032",
"washer" => "ISO 7089",
@@ -725,10 +784,12 @@ fn set_identity(s: &mut Session, comp: u64, now: (String, String), was: Option<(
}
}
/// The short label a family's parts are named with: `SHCS` in `SHCS M6x20`, `CSK` in `CSK M6x20`.
fn label(family: &str) -> &'static str {
match family {
"socket_head_cap_screw" => "SHCS",
"button_head_cap_screw" => "BHCS",
"countersunk_socket_screw" => "CSK",
"hex_bolt" => "Hex Bolt",
"hex_nut" => "Hex Nut",
"washer" => "Washer",
@@ -765,6 +826,8 @@ fn insert_atomic(s: &mut Session, p: &Value) -> Result<Value> {
Ok(result)
}
/// `parts.insert`: build the part as its own component, then seat it with a rigid joint at each
/// hole picked in `at`, or place it at `point`, or leave it at the origin.
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parts.insert (Insert Fastener)";
let family = str_(p, "family").ok_or_else(|| bad(cmd, "`family` is required (parts.library)"))?.to_string();
@@ -776,7 +839,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let mut holes: Vec<(Vec3, u64)> = Vec::new();
let mut seats: Vec<(u64, HoleWall)> = Vec::new();
for a in hole_picks(p, cmd)? {
let wall = hole_wall(s, a).unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true });
let wall = hole_wall(s, a).unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true, radius: 0.0 });
let host = super::joints::occurrence_at(s, wall.at).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?;
// A rim is drawn as more than one edge, and one click on it picks them all; a hole can
// also be picked at both its ends (#12191). The picks of one hole seat one part, at an
@@ -832,10 +895,23 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
// Seat it: its axis on the hole's, its seating plane on the hole's face, the shank
// going into the hole (the joint mates the part's −z with the face's outward z).
let seat = if i == 0 { format!("{name} seat") } else { format!("{name} seat {}", i + 1) };
// A countersunk head sinks into the part: the foot of its cone, not its top, goes on
// the rim picked (where the hole's cylinder meets its countersink), moved along the
// axis by the bore's radius over the shank's, so that its 90 degree cone lies on the
// countersink's, whose cone is as wide as the head's at the face. The difference is
// signed: a clearance hole is wider than the shank and the cone goes deeper, a tap
// drill is narrower and the cone goes higher. With no bore known, there is no move.
let at_rim = match sh {
Shape::Screw { d, head: Head::Countersunk { k, .. }, .. } => {
let clearance = seats.get(i).map_or(0.0, |(_, w)| if w.radius > 0.0 { w.radius - d / 2.0 } else { 0.0 });
[0.0, 0.0, -k + clearance]
}
_ => [0.0, 0.0, 0.0],
};
let j = run(
s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": seat}),
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": at_rim, "z": [0, 0, -1]}, "name": seat}),
)?;
joints.push(j["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint has no id"))?);
}
@@ -895,6 +971,8 @@ struct HoleWall {
ends: Option<[Vec3; 2]>,
/// The picked rim opens onto air, not onto the floor of a blind hole.
open: bool,
/// The wall's radius (0 when the pick is not on a hole).
radius: f64,
}
impl HoleWall {
@@ -937,7 +1015,14 @@ fn hole_wall(s: &Session, a: Vec3) -> Option<HoleWall> {
let past = c.axis_point + axis * (end + out * (c.end - c.start).min(1.0) * 0.25);
let open = !matches!(b.body.classify_point(past), Ok(solidcraft_doc::kernel::PointClass::Inside));
let ends = [c.axis_point + axis * c.start, c.axis_point + axis * c.end];
return Some(HoleWall { at: q, seat: c.axis_point + axis * along, axis: Some(axis), ends: Some(ends), open });
return Some(HoleWall {
at: q,
seat: c.axis_point + axis * along,
axis: Some(axis),
ends: Some(ends),
open,
radius: c.radius,
});
}
}
}
@@ -1200,6 +1285,91 @@ mod tests {
assert_eq!(number, Some("ISO 7380-1 M6x20"));
}
/// #13361: a countersunk socket screw (ISO 10642) takes the same parameters as the other
/// screws, with a 90° cone sunk below its seating plane: the top of the head is level with
/// the face, and the length counts the head.
#[test]
fn a_countersunk_head_is_a_cone_sunk_below_the_seating_plane() {
let l = run(&mut Session::default(), "parts.library", json!({"family": "countersunk_socket_screw"}));
let row = |size: &str| l["sizes"].as_array().and_then(|a| a.iter().find(|x| x["size"] == size)).cloned().unwrap_or_default();
let m6 = row("M6");
let dims = [&m6["head_diameter"], &m6["head_height"], &m6["socket"], &m6["socket_depth"]].map(|v| v.as_f64());
assert_eq!(dims, [Some(13.44), Some(3.72), Some(4.0), Some(2.2)]);
// M2 and M2.5 are left out (one source only), and the cone is 90° at every size: k is half
// the way from the shank to dk.
let sizes: Vec<&str> = l["sizes"].as_array().map(|a| a.iter().filter_map(|x| x["size"].as_str()).collect()).unwrap_or_default();
assert_eq!(sizes, ["M3", "M4", "M5", "M6", "M8", "M10", "M12", "M14", "M16", "M20"]);
for size in sizes {
let (d, dk, k) = (
row(size)["d"].as_f64().unwrap_or(0.0),
row(size)["head_diameter"].as_f64().unwrap_or(0.0),
row(size)["head_height"].as_f64().unwrap_or(0.0),
);
assert!(((dk - d) / 2.0 - k).abs() < 0.006, "{size}: dk {dk}, d {d}, k {k}");
}
assert!(l["lengths"].as_array().is_some_and(|a| !a.is_empty()), "{l}");
// An M6 x 20: head Ø13.44 at the top, which is z = 0; the whole 20 hangs below it.
let mut s = Session::default();
run(&mut s, "parts.insert", json!({"family": "countersunk_socket_screw", "size": "M6", "length": 20}));
let (lo, hi) = bbox(&s, "CSK M6x20");
assert!((hi.x - lo.x - 13.44).abs() < 1e-3 && (lo.z + 20.0).abs() < 1e-6 && hi.z.abs() < 1e-6, "{lo:?} {hi:?}");
assert!(s.model.state().threads.iter().any(|t| t.designation.starts_with("M6")), "{:?}", s.model.state().threads);
// The profile: the rim at the top, the shank (Ø6) below the cone, and the cone's foot
// 3.72 down, where it meets the shank.
let st = s.world_state().unwrap();
let points: Vec<_> = st.bodies.iter().flat_map(|b| b.mesh().positions.clone()).collect();
let radius = |p: &solidcore_record::Vec3| p.x.hypot(p.y);
let rim = points.iter().filter(|p| p.z > -0.01).map(radius).fold(0.0, f64::max);
let shank = points.iter().filter(|p| p.z < -3.8).map(radius).fold(0.0, f64::max);
// The cone's foot is the highest ring of the shank's radius below the top; the mesh
// splits the cone itself into rings of its own, so the cone is not measured by vertices.
let foot = points.iter().filter(|p| (radius(p) - 3.0).abs() < 1e-3 && p.z < -0.5).map(|p| p.z).fold(f64::MIN, f64::max);
assert!((rim - 6.72).abs() < 1e-3, "the rim: {rim}");
assert!((shank - 3.0).abs() < 1e-3, "the shank: {shank}");
assert!((foot + 3.72).abs() < 1e-3, "the cone's foot: {foot}");
let comp = s.doc.components.iter().find(|c| c.name.starts_with("CSK")).map(|c| c.id).unwrap_or(0);
let number = s.doc.components.iter().find(|c| c.id == comp).and_then(|c| c.metadata.text(solidcraft_doc::metadata::PART_NUMBER));
assert_eq!(number, Some("ISO 10642 M6x20"));
// The length counts the head, so one no longer than the head is refused, and nothing is made.
let mut short = Session::default();
assert!(short.execute("parts.insert", &json!({"family": "countersunk_socket_screw", "size": "M20", "length": 8})).is_err());
assert!(short.doc.components.is_empty());
}
/// #13361: seated at a countersunk hole (picked where the countersink meets the bore), the
/// head's cone lies in the countersink and its top is level with the face, not at the bore.
#[test]
fn a_countersunk_screw_seats_flush_in_a_countersunk_hole() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
let hole = json!({"position": [20, 15, 10], "type": "countersink", "diameter": 6.6, "cs_diameter": 13.44, "cs_angle": "90 deg", "depth": 10});
run(&mut s, "solid.hole", hole);
let r = run(&mut s, "parts.fastener", json!({"family": "countersunk_socket_screw", "size": "M6", "length": 20, "at": [23.3, 15, 6.4]}));
assert!(r["joint"].as_u64().is_some(), "{r}");
// The plate's face is z = 10: the head's top is there, and the 20 mm length hangs from it.
let (lo, hi) = bbox(&s, "CSK M6x20");
assert!((hi.z - 10.0).abs() < 0.02 && (lo.z + 10.0).abs() < 0.02, "{lo:?} {hi:?}");
assert!((hi.x - lo.x - 13.44).abs() < 1e-3, "{lo:?} {hi:?}");
}
/// #13361: in a tapped countersink the bore is a tap drill, narrower than the screw's shank, so
/// the cone sits higher on the rim than in a clearance hole, and the head is still level with
/// the face (it was 0.6 mm off for an M8 when the difference of the radii was cut off at 0).
#[test]
fn a_countersunk_screw_seats_flush_in_a_tapped_countersink() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
let hole =
json!({"position": [20, 15, 10], "type": "countersink", "thread": "M8x1.25", "cs_diameter": 17.92, "cs_angle": "90 deg", "depth": 10});
run(&mut s, "solid.hole", hole);
// The tap drill is 6.8, so the bore's wall is at r = 3.4 and meets the countersink at z = 4.44.
let r = run(&mut s, "parts.fastener", json!({"family": "countersunk_socket_screw", "size": "M8", "length": 20, "at": [23.4, 15, 4.3]}));
assert!(r["joint"].as_u64().is_some(), "{r}");
let (lo, hi) = bbox(&s, "CSK M8x20");
assert!((hi.z - 10.0).abs() < 0.02 && (lo.z + 10.0).abs() < 0.02, "{lo:?} {hi:?}");
assert!((hi.x - lo.x - 17.92).abs() < 1e-3, "{lo:?} {hi:?}");
}
/// #2091: the second and larger ISO sizes, M2, M2.5, M14, M24 and M30, are in the screw, bolt,
/// nut and washer tables (they stopped at M3 and M20).
#[test]
@@ -1409,11 +1579,13 @@ mod tests {
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(2), "one screw per hole: {r}");
}
/// Every family's sketches are fully defined when the part is inserted and when it is resized.
#[test]
fn every_sketch_of_a_standard_part_is_fully_defined() {
for (family, size, resized) in [
("socket_head_cap_screw", "M6", "M8"),
("button_head_cap_screw", "M6", "M8"),
("countersunk_socket_screw", "M6", "M8"),
("hex_bolt", "M6", "M8"),
("hex_nut", "M6", "M8"),
("washer", "M6", "M8"),
@@ -1438,11 +1610,13 @@ mod tests {
}
}
/// A placed part is resized in its own frame and in millimetres, whatever the design's units.
#[test]
fn resizing_placed_parts_uses_local_geometry_and_millimetres_in_every_design_unit() {
for (family, initial, resized, prefix) in [
("socket_head_cap_screw", "M6", "M8", "SHCS"),
("button_head_cap_screw", "M6", "M8", "BHCS"),
("countersunk_socket_screw", "M6", "M8", "CSK"),
("hex_bolt", "M6", "M8", "Hex Bolt"),
("hex_nut", "M6", "M8", "Hex Nut"),
("washer", "M6", "M8", "Washer"),
@@ -1459,7 +1633,10 @@ mod tests {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{family} {units}");
let b = bbox(&s, prefix);
assert!(b.0.x < b.1.x && b.0.y < b.1.y && b.0.z < b.1.z, "{family} {units}: {b:?}");
if matches!(family, "socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "hex_nut" | "set_screw") {
if matches!(
family,
"socket_head_cap_screw" | "button_head_cap_screw" | "countersunk_socket_screw" | "hex_bolt" | "hex_nut" | "set_screw"
) {
assert!(s.model.state().threads.iter().any(|t| t.designation == "M8" && (t.pitch - 1.25).abs() < 1e-9), "{family} {units}");
}
if family == "dowel_pin" {
File diff suppressed because one or more lines are too long
+232 -17
View File
@@ -1,6 +1,37 @@
use super::*;
use solidcraft_render::job::{CpuHost, JobId};
#[cfg(not(target_arch = "wasm32"))]
// Validate each live receipt before comparing deterministic accounting. RAM
// snapshots and other retained leases can change between otherwise equal checks.
fn preflight_without_memory_observations(mut reply: Value) -> Value {
let total = reply["admission"]["estimated_total_bytes"].as_u64().unwrap();
let memory = reply["admission"]["host_memory"].as_object_mut().expect("native preflight reports its RAM admission");
let required = memory["required_bytes"].as_u64().unwrap();
let admitted = memory["admitted_bytes"].as_u64().unwrap();
let credited = memory["credited_bytes"].as_u64().unwrap();
let additional = memory["additional_bytes"].as_u64().unwrap();
let physical = memory["physical_debit_bytes"].as_u64().unwrap();
let available = memory["available_bytes"].as_u64().unwrap();
let capacity = memory["capacity_bytes"].as_u64().unwrap();
assert_eq!(required, total, "native debit includes the complete public admission");
assert_eq!(required.checked_sub(credited), Some(additional));
assert_eq!(credited, required, "native preflight reports its fully funded prepared capture");
assert_eq!(additional, 0, "the final capture self-check needs no new allocation");
assert!(additional <= available, "new native debit fits its available RAM: {memory:?}");
assert_eq!(available, capacity.saturating_sub(physical), "physical debit accounts for the snapshot allowance");
assert!(admitted.checked_add(required).unwrap() <= memory["software_limit_bytes"].as_u64().unwrap());
assert!(
matches!(memory["source"].as_str(), Some("system_available" | "system_and_cgroup" | "conservative_fallback")),
"native receipt identifies its capacity source"
);
// Keep required_bytes and every non-observation field in the exact comparison.
for field in ["admitted_bytes", "available_bytes", "capacity_bytes", "physical_debit_bytes", "source"] {
memory.remove(field).unwrap();
}
reply
}
fn session() -> (Session, CpuHost) {
let host = CpuHost::cooperative();
let mut s = Session::default();
@@ -128,7 +159,7 @@ fn saved_gpu_job_routes_before_dispatch_and_reaches_preflight_and_start_without_
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
let checked = s.execute("render.preflight", &named).unwrap();
let inline = s.execute("render.preflight", &json!({"settings":saved})).unwrap();
assert_eq!(checked, inline);
assert_eq!(preflight_without_memory_observations(checked.clone()), preflight_without_memory_observations(inline));
assert_eq!(checked["resolved"]["executor"], "gpu-path");
assert_eq!(checked["options"]["exposure_ev"], 1.75);
assert_eq!(checked["options"]["samples"], 7);
@@ -209,7 +240,10 @@ fn saved_gpu_job_routes_before_dispatch_and_reaches_preflight_and_start_without_
let doc = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
let checked = s.execute("render.preflight", &json!({"setup":"HDR"})).unwrap();
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap());
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap())
);
assert_eq!(checked["environment"]["source_sha256"], info["sha256"]);
assert_eq!(checked["environment"]["kind"], "radiance_hdr");
assert_eq!(checked["environment"]["color_policy"], "declared_linear_srgb_d65");
@@ -253,7 +287,10 @@ fn saved_cpu_job_reopens_and_supplies_exact_options_and_one_exposure_to_check_an
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
let named = json!({"setup":"CPU"});
let checked = s.execute("render.preflight", &named).unwrap();
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":saved})).unwrap())
);
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["integrator"], "raytrace");
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], 1.25);
@@ -300,7 +337,10 @@ fn saved_cpu_hdr_reopens_without_source_and_drives_identical_check_start_and_rea
let doc = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
let checked = s.execute("render.preflight", &json!({"setup":"HDR"})).unwrap();
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap());
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap())
);
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], 1.25);
assert_eq!(checked["environment"]["samples"], 64);
assert_eq!(checked["environment"]["rotation_radians"], 0.75);
@@ -308,7 +348,10 @@ fn saved_cpu_hdr_reopens_without_source_and_drives_identical_check_start_and_rea
let runtime = json!({"integrator":"raytrace","settings":{"width":9,"height":9,"background":[13,27,41]},
"raytrace":{"lights":[],"shadows":false,"primary_samples":4,"exposure_ev":1.25},
"environment":{"resource":info["sha256"],"rotation_radians":0.75,"intensity":2,"samples":64}});
assert_eq!(checked, s.execute("render.preflight", &runtime).unwrap());
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &runtime).unwrap())
);
for environment in [
json!({"resource":"0".repeat(64)}),
json!({"resource":info["sha256"],"path":"must-not-open.hdr"}),
@@ -425,7 +468,7 @@ fn additional_executor_routes_by_capability_and_survives_document_adoption() {
session.execute("file.new", &json!({})).unwrap();
assert_eq!(session.execute("render.preflight", &request).unwrap()["resolved"]["executor"], "test-progressive");
assert!(session.execute("render.status", &json!({"job":id})).is_err());
for bad in [json!({"samples":0}), json!({"max_depth":17}), json!({"seed":-1}), json!({"samples":4097})] {
for bad in [json!({"samples":0}), json!({"max_depth":65}), json!({"seed":-1}), json!({"samples":65537})] {
let mut params = request.clone();
for (key, value) in bad.as_object().unwrap() {
params[key] = value.clone();
@@ -1512,7 +1555,9 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
s.execute(
"render.settings.set",
&json!({"name":"CPU denoise","set":{"width":8,"height":8,
"job":{"method":"cpu_raytrace","options":{"denoise":true,"progressive":{"samples_per_pixel":3,"seed":81},"output":{"linear_exr":true}}}}}),
"job":{"method":"cpu_raytrace","options":{"threads":2,"supersampling":2,"denoise":true,
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false,"optical_filter":false},"denoise_diagnostics":true,
"progressive":{"samples_per_pixel":3,"seed":81},"output":{"linear_exr":true}}}}}),
)
.unwrap();
s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
@@ -1524,7 +1569,15 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["raytrace"]["denoise"], true);
assert_eq!(checked["denoising"]["backend"], "native_cpu");
assert_eq!(checked["denoising"]["passes"], 3);
assert_eq!(checked["denoising"]["passes"], 2);
assert_eq!(checked["denoising"]["steps"], json!([1, 2]));
assert_eq!(checked["denoising"]["settings"], json!({"passes":2,"strength":0.5,"firefly_suppression":false,"optical_filter":false}));
assert_eq!(checked["denoising"]["outlier_policy"], "disabled");
assert_eq!(checked["denoising"]["optical_policy"], "legacy opaque-only filtering; mirror and glass radiance bypass display filtering");
assert_eq!(checked["request"]["raytrace"]["denoise_settings"]["optical_filter"], false);
assert_eq!(checked["request"]["raytrace"]["threads"], 2);
assert_eq!(checked["request"]["raytrace"]["supersampling"], 2);
assert_eq!(checked["request"]["raytrace"]["denoise_diagnostics"], true);
assert_eq!(checked["denoising"]["raw_linear_accumulator_preserved"], true);
let started = s.execute("render.start", &json!({"setup":"CPU denoise"})).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
@@ -1534,13 +1587,49 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
assert_eq!(json!(status.request), checked["request"]);
let frame = handle.frame().unwrap().unwrap();
let raw = frame.linear_exr().unwrap().to_vec();
assert_eq!((frame.info.width, frame.info.height), (8, 8));
assert_eq!(image::load_from_memory(frame.png()).unwrap().to_rgba8().dimensions(), (8, 8));
let report = status.denoise_diagnostics.unwrap();
assert_eq!((report.width, report.height), (16, 16));
assert_eq!(report.counts.iter().sum::<u64>(), 256);
let mask = frame.denoise_mask_png().unwrap().to_vec();
let mask_info = frame.denoise_mask_info().unwrap();
assert_eq!((mask_info.width, mask_info.height), (8, 8));
assert_eq!(mask_info.bytes, mask.len() as u64);
assert_ne!(mask_info.sha256, frame.info.png_sha256);
let decoded = image::load_from_memory(&mask).unwrap().to_rgba8();
assert_eq!(decoded.dimensions(), (8, 8));
assert!(decoded.pixels().all(|pixel| solidcraft_render::job::DenoiseGuideClass::ALL.iter().any(|class| class.color() == pixel.0)));
let status_reply = s.execute("render.status", &json!({"job":id})).unwrap();
assert_eq!(status_reply["denoise_mask"], json!(mask_info));
assert_eq!(status_reply["denoise_diagnostics"], json!(report));
let viewed = s.execute("render.result.view", &json!({"view":"denoise_mask"})).unwrap();
assert_eq!(viewed, json!({"view":"denoise_mask","job":id}));
let path = std::env::temp_dir().join(format!("solidcraft-cpu-guide-mask-{}-{}.png", std::process::id(), id.session));
let previous_check = s.param_check;
s.param_check = crate::schema::ParamCheck::Strict;
let exported = s.execute("render.export", &json!({"job":id,"path":path,"format":"denoise_mask"})).unwrap();
s.param_check = previous_check;
assert_eq!(std::fs::read(&path).unwrap(), mask);
assert_eq!(exported["format"], "denoise_mask");
assert_eq!(exported["encoding"], "categorical_srgb");
assert_eq!(exported["alpha"], "opaque");
assert_eq!(exported["bytes"], mask_info.bytes);
assert!(exported.get("param_warnings").is_none());
assert_eq!(exported["denoise_mask"], json!(mask_info));
assert_eq!(exported["frame"]["png_sha256"], frame.info.png_sha256);
std::fs::remove_file(path).unwrap();
let result = s.execute("render.result.regrade", &json!({"job":id,"presentation":{"exposure_ev":-1.0}})).unwrap();
let derivative: JobId = serde_json::from_value(result["job"].clone()).unwrap();
let derivative = s.render_job_handle(derivative).unwrap();
let finished = derivative.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
assert_eq!(finished.progress.phase, Phase::Completed);
assert!(!finished.presentation.as_ref().unwrap().denoise_applied, "regrade never pretends to reconstruct absent geometry guides");
assert_eq!(derivative.frame().unwrap().unwrap().linear_exr().unwrap(), raw);
let derivative_frame = derivative.frame().unwrap().unwrap();
assert_eq!(derivative_frame.linear_exr().unwrap(), raw);
assert_eq!(derivative_frame.denoise_mask_png().unwrap(), mask);
assert_eq!(json!(derivative_frame.denoise_mask_info()), json!(Some(mask_info)));
assert_eq!(finished.denoise_diagnostics, Some(report));
assert_eq!(frame.linear_exr().unwrap(), raw);
assert!(Arc::ptr_eq(&document, &s.doc));
assert_eq!((s.revision, s.evaluations, s.undo.len(), s.redo.len()), before);
@@ -1594,12 +1683,15 @@ fn saved_cpu_optics_match_runtime_capture_pixels_prefixes_cancel_and_accounting(
let extra_bytes = (extra_slots * std::mem::size_of::<solidcraft_render::job::DielectricOptions>()) as u64;
assert_eq!(saved["retained_request_bytes"].as_u64().unwrap() + extra_bytes, runtime["retained_request_bytes"].as_u64().unwrap());
// Two options copies belong to image work; a third belongs to the retained
// immutable request. All other admission fields must remain exactly equal.
let mut expected_runtime_admission = saved["admission"].clone();
// immutable request. All other deterministic admission fields remain exact.
let mut expected_runtime_admission = preflight_without_memory_observations(saved.clone())["admission"].clone();
for (field, copies) in [("preparation_bytes", 2), ("estimated_total_bytes", 3)] {
expected_runtime_admission[field] = json!(saved["admission"][field].as_u64().unwrap() + copies * extra_bytes);
}
assert_eq!(expected_runtime_admission, runtime["admission"]);
for field in ["required_bytes", "credited_bytes"] {
expected_runtime_admission["host_memory"][field] = json!(saved["admission"]["host_memory"][field].as_u64().unwrap() + 3 * extra_bytes);
}
assert_eq!(expected_runtime_admission, preflight_without_memory_observations(runtime.clone())["admission"]);
assert_eq!(saved["refraction"], true);
let original_mesh = scene.meshes[0].mesh.clone();
assert!(!scene.has_transmission(), "saved CPU lowering leaves the captured product appearance and physical material unchanged");
@@ -1925,7 +2017,10 @@ fn saved_cpu_aging_cold_reopen_resolves_exact_target_and_changes_real_png_withou
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
let checked = s.execute("render.preflight", &json!({"setup":"CPU aging"})).unwrap();
assert_eq!(checked["request"]["raytrace"]["aging"][0]["target"]["body_name"], name);
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":saved})).unwrap())
);
let started = s.execute("render.start", &json!({"setup":"CPU aging"})).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
@@ -2229,8 +2324,9 @@ fn runtime_checked_hdr_keeps_two_stable_glass_targets_and_exact_cpu_options_with
raster["environment"]["path"] = json!("must-not-open-raster-stable-glass.hdr");
assert!(s.execute("render.preflight", &raster).unwrap_err().to_string().contains("stable optics require"));
let mut over_cap = params.clone();
let optical_limit = solidcraft_render::job::MAX_OPTICAL_TARGETS;
over_cap["settings"]["optics"] = json!(
(0..9)
(0..=optical_limit)
.map(|i| {
let mut row = params["settings"]["optics"][0].clone();
row["target"]["definition"] = json!(format!("Unique unknown optical body {i}"));
@@ -2239,7 +2335,7 @@ fn runtime_checked_hdr_keeps_two_stable_glass_targets_and_exact_cpu_options_with
.collect::<Vec<_>>()
);
over_cap["environment"]["path"] = json!("must-not-open-over-cap-stable-glass.hdr");
assert!(s.execute("render.preflight", &over_cap).unwrap_err().to_string().contains("at most eight"));
assert!(s.execute("render.preflight", &over_cap).unwrap_err().to_string().contains(&format!("at most {optical_limit}")));
let mut changed_hdr = params.clone();
changed_hdr["environment"]["expected_sha256"] = json!("0".repeat(64));
assert!(s.execute("render.preflight", &changed_hdr).unwrap_err().to_string().contains("changed since selection"));
@@ -2277,7 +2373,7 @@ fn saved_cpu_path_maps_glass_and_targeted_aging_reach_real_preflight_and_render_
s.execute(
"render.settings.set",
&json!({"name":"Mixed","set":{"camera":camera,"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{
"progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":4},"lights":[],
"texture_filtering":true,"progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":4},"lights":[],
"environment":{"color":[1,1,1],"intensity":1},"output":{"linear_exr":true},
"aging":[{"kind":"fading","target":panel["stable_target"],"strength":0.25,"coverage":1.0,"upward_bias":0.0}]
}}}}),
@@ -2301,6 +2397,8 @@ fn saved_cpu_path_maps_glass_and_targeted_aging_reach_real_preflight_and_render_
let history = (s.revision, s.evaluations, s.undo.len(), s.redo.len());
let checked = s.execute("render.preflight", &json!({"setup":"Mixed"})).unwrap();
assert_eq!(checked["textures"]["bodies"], 1);
assert_eq!(checked["textures"]["filtering"], true);
assert_eq!(checked["request"]["raytrace"]["texture_filtering"], true);
assert_eq!(checked["request"]["raytrace"]["path"]["max_depth"], 4);
assert_eq!(checked["request"]["raytrace"]["dielectrics"][0]["body_name"], "Glass");
assert_eq!(checked["request"]["raytrace"]["dielectrics"][0]["boundary"], "closed_mesh");
@@ -2470,7 +2568,11 @@ fn saved_coat_cpu_direct_and_path_render_match_independent_normal_incidence_laye
let mut plain = setup.clone();
plain.coats = Default::default();
let plain_check = s.execute("render.preflight", &json!({"settings":plain})).unwrap();
assert_eq!(checked["admission"], plain_check["admission"], "coat uses existing inline surface metadata and the same bounded producer stages");
assert_eq!(
preflight_without_memory_observations(checked.clone())["admission"],
preflight_without_memory_observations(plain_check.clone())["admission"],
"coat uses existing inline surface metadata and the same bounded producer stages"
);
assert_eq!(checked["retained_request_bytes"], plain_check["retained_request_bytes"]);
let mut zero = setup;
let row = zero.coats.iter().next().unwrap().clone();
@@ -3173,6 +3275,56 @@ mod native_camera_queue_preparation {
assert_eq!((s.revision, s.evaluations, s.undo.len(), s.redo.len()), history);
}
#[test]
fn native_batch_retained_plan_and_completed_images_allow_a_new_ram_epoch_before_next_capture() {
let (mut s, _) = fixture(2);
let host = CpuHost::for_platform().unwrap();
s.set_render_job_host(Some(host.clone())).unwrap();
let wait_released = |handle: &solidcraft_render::job::JobHandle| {
assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(10)).unwrap().progress.phase, Phase::Completed);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !handle.work_released() && std::time::Instant::now() < deadline {
std::thread::yield_now();
}
assert!(handle.work_released(), "terminal publication must finish releasing task backing");
};
let (mut first, worker) = initial(&mut s, json!({}));
batch::finish_retained(&mut s, &mut first).unwrap();
let mut plan = batch::take_plan(&mut first).unwrap();
let plan_bytes = plan.as_ref().unwrap().retained_bytes();
worker.retire(first);
let first_handle = s.render_job_handle(s.render_queue_state().job.unwrap()).unwrap();
wait_released(&first_handle);
let frame = first_handle.frame().unwrap().unwrap();
let held = host.admitted_bytes();
assert!(held > plan_bytes, "the Plan, request metadata, and completed image remain owned");
let receipt = s.execute("render.preflight", &json!({"setup":"Shot00"})).unwrap();
let memory = &receipt["admission"]["host_memory"];
assert_eq!(memory["admitted_bytes"], json!(held));
let credit = memory["credited_bytes"].as_u64().unwrap();
assert!(credit > 0, "engine preflight owns a newly funded capture at its receipt check");
assert_eq!(memory["required_bytes"], json!(credit));
assert_eq!(memory["additional_bytes"], json!(0));
let expected_debit = if memory["source"] == "conservative_fallback" { held.checked_add(credit).unwrap() } else { credit };
assert_eq!(memory["physical_debit_bytes"], json!(expected_debit), "only the new capture debits a refreshed actual-RAM snapshot");
assert_eq!(host.admitted_bytes(), held, "rebasing does not release software accounting");
// The next camera borrows old-epoch Plan metadata while funding a new
// capture. Its final held/held self-check must not count that credit twice.
let claim = batch::claim_next(&mut s, &mut plan, CancelToken::default()).unwrap().unwrap();
assert!(plan.is_none());
let (mut next, worker) = Worker::prepare(claim);
let reply = batch::finish_retained(&mut s, &mut next).unwrap();
assert!(batch::take_plan(&mut next).unwrap().is_none());
worker.retire(next);
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
let second_handle = s.render_job_handle(id).unwrap();
wait_released(&second_handle);
assert!(second_handle.frame().unwrap().is_some());
assert!(!frame.png().is_empty(), "the earlier image remains valid across the next epoch");
}
#[test]
fn native_batch_owner_and_cancelled_identical_front_cannot_publish_into_wrong_or_replacement_registry() {
let (mut s, host) = fixture(2);
@@ -3648,3 +3800,66 @@ fn tessellated_cpu_sheet_saved_and_prospective_bindings_certify_and_render_witho
assert!(Arc::ptr_eq(&document, &s.doc) && Arc::ptr_eq(&geometry, &s.model.state()));
assert_eq!(history, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty()));
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn saved_final_cpu_image_preserves_mode_executor_and_authored_quality_through_start() {
let mut session = Session::default();
let host = CpuHost::for_platform().unwrap();
session.set_render_job_host(Some(host)).unwrap();
session.execute("solid.box", &json!({"length":2,"width":3,"height":4})).unwrap();
session
.execute(
"render.settings.set",
&json!({"name":"Final OPUS reference","set":{
"mode":"final","width":2,"height":2,"job":{"method":"cpu_raytrace","options":{
"budget":"workstation","path":{"max_depth":64},"progressive":{"samples_per_pixel":3,"seed":81},
"lights":[],"environment":{"color":[0.1,0.2,0.3],"intensity":1},"output":{"linear_exr":true}
}}}}),
)
.unwrap();
let params = json!({"setup":"Final OPUS reference"});
let before = session.doc.clone();
let preflight = session.execute("render.preflight", &params).unwrap();
assert_eq!(preflight["resolved"]["profile"]["mode"], "final");
assert_eq!(preflight["resolved"]["executor"], "cpu-raytrace");
let started = session.execute("render.start", &params).unwrap();
let id = serde_json::from_value(started["job"].clone()).unwrap();
let job = session.render_job_handle(id).unwrap();
let status = job.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Completed);
assert_eq!(status.resolved.profile.mode, solidcraft_render::RenderMode::Final);
assert_eq!(status.resolved.executor.as_str(), "cpu-raytrace");
let options = status.request.raytrace.unwrap();
assert_eq!(options.path.unwrap().max_depth, 64);
assert_eq!(options.progressive.unwrap().samples_per_pixel, 3);
assert!(job.frame().unwrap().unwrap().linear_master_info().is_some());
assert!(Arc::ptr_eq(&before, &session.doc));
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn cpu_quality_limits_refuse_before_input_io_and_scene_capture() {
let mut s = Session::default();
let host = CpuHost::for_platform().unwrap();
s.set_render_job_host(Some(host.clone())).unwrap();
let document = s.doc.clone();
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len());
for (width, height, options) in [
(1024, 1024, json!({"supersampling":2,"progressive":{"samples_per_pixel":8}})),
(8, 8, json!({"threads":65})),
(8, 8, json!({"supersampling":3})),
(8, 8, json!({"denoise_settings":{"passes":4}})),
(8193, 1, json!({"budget":"workstation","denoise_diagnostics":true})),
] {
let request = json!({"integrator":"raytrace","settings":{"width":width,"height":height},
"raytrace":options,"environment":{"path":"must-not-open-quality-controls.hdr"}});
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &request).unwrap_err().to_string();
assert!(!error.contains("must-not-open-quality-controls.hdr"), "quality must refuse before source IO: {error}");
assert_eq!(host.admitted_bytes(), 0);
assert!(Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len()));
}
}
}
+97 -18
View File
@@ -12,6 +12,11 @@ use solidcraft_render::{
job::{ImageRequest, Integrator, JobId, RaytraceOptions},
};
const DENOISE_OUTLIER_POLICY: &str = "suppress isolated high radiance on locally matching guides in display output";
const DENOISE_OPTICAL_POLICY: &str =
"supported sharp reflections and smooth glass use separate reflected and transmitted endpoint geometry and material guides";
const DENOISE_OPAQUE_ONLY_POLICY: &str = "legacy opaque-only filtering; mirror and glass radiance bypass display filtering";
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Start {
@@ -52,11 +57,16 @@ impl Default for EnvironmentRequest {
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ProgressiveStart {
supersampling: Option<u32>,
denoise_settings: solidcraft_render::job::DenoiseSettings,
denoise_diagnostics: bool,
caustics: Option<solidcraft_render::job::CausticOptions>,
studio_weights: Option<[f64; 3]>,
aging: solidcraft_doc::render_aging::SavedAgingStack,
#[serde(default)]
nested_media: bool,
texture_filtering: bool,
metal_energy_compensation: bool,
volume: Option<solidcraft_render::job::volume::HomogeneousVolume>,
adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
transmission_samples: Option<u32>,
@@ -197,8 +207,9 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
"samples":options.samples,"max_depth":options.max_depth,"seed":options.seed,
"lens":options.lens,"lighting":options.lighting,"tone_map":options.tone_map,
"denoise":options.denoise,"environment":options.environment,
"supersampling":options.supersampling,"denoise_settings":options.denoise_settings,"denoise_diagnostics":options.denoise_diagnostics,
"finishing":options.finishing,"backdrop":options.backdrop,"linear_exr":options.linear_exr,
"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
"texture_filtering":options.texture_filtering,"metal_energy_compensation":options.metal_energy_compensation,"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
})
}
};
@@ -250,7 +261,7 @@ pub(crate) fn prepare_with_metadata(
params.get("raytrace").and_then(Value::as_object).is_some_and(|o| ["clear_coats", "textures"].iter().any(|key| o.contains_key(*key)));
if gpu_raytrace
|| params.get("environment").is_some_and(|e| e.get("kind").is_some())
|| ["lens", "lighting", "tone_map"].iter().any(|key| params.get(*key).is_some())
|| ["lens", "lighting", "tone_map", "supersampling", "denoise_settings", "denoise_diagnostics"].iter().any(|key| params.get(*key).is_some())
|| params.get("denoise").is_some_and(|value| value != &json!(false))
{
return Err(bad(
@@ -354,7 +365,15 @@ pub(crate) fn prepare_with_metadata(
let defaults = RenderSetup::default();
(defaults.width, defaults.height)
};
options.budget.validate_image(dimensions.0, dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
let trace_dimensions = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
dimensions
} else {
solidcraft_render::job::supersampled_dimensions(dimensions.0, dimensions.1, options.supersampling).map_err(job_error)?
};
options.budget.validate_image(trace_dimensions.0, trace_dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
if options.denoise || options.denoise_diagnostics {
solidcraft_render::job::validate_cpu_denoise_image(trace_dimensions.0, trace_dimensions.1).map_err(job_error)?;
}
if let Some(lens) = options.lens {
let camera = input
.setup
@@ -434,6 +453,19 @@ pub(crate) fn prepare_with_metadata(
}
options.dielectrics = super::optical_bindings::resolve_cpu_path(s, &scene, bindings, options.path.is_some(), command)?;
options.validate().map_err(job_error)?;
// An explicit optical binding owns transmission. Viewport opacity is
// only a display proxy and must not enter CPU transport.
for optical in &options.dielectrics {
let body = scene
.meshes
.get_mut(optical.body_index)
.filter(|body| body.name == optical.body_name)
.ok_or_else(|| bad(command, "saved optical target changed during capture"))?;
body.opacity = 1.0;
for face in &mut body.faces {
face.opacity = 1.0;
}
}
}
super::coat_bindings::apply(s, &mut scene, &coats, input.raytrace.as_ref(), command)?;
if !input.aging.is_empty() {
@@ -523,7 +555,13 @@ pub(crate) fn prepare_with_metadata(
}
let textures = if let Some(bindings) = saved_surfaces {
let overrides = super::surface_bindings::resolve(s, &scene, bindings, command)?;
super::textures::capture_with_resources(&scene, &overrides, Some(&s.doc.render.texture_resources), command)?
super::textures::capture_with_resources_and_filtering(
&scene,
&overrides,
Some(&s.doc.render.texture_resources),
command,
input.raytrace.as_ref().is_some_and(|options| options.texture_filtering),
)?
} else {
None
};
@@ -539,9 +577,13 @@ pub(crate) fn prepare_with_metadata(
background: settings.background,
transparent_background: input.transparent_background,
};
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
let submission =
let environment_bytes = environment
.as_ref()
.map_or(Ok(0), |e| e.required_bytes_with_importance(request.raytrace.as_ref().is_some_and(|options| options.environment_importance_sampling)))
.map_err(|e| bad(command, e.to_string()))?;
let mut submission =
host.prepare_submission_with_metadata(scene, request, Default::default(), environment, cancel.clone(), retained).map_err(job_error)?;
submission.select_image_mode(settings.mode).map_err(job_error)?;
let request = submission.request();
let mut admission = submission.admission();
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
@@ -583,12 +625,16 @@ pub(crate) fn prepare_with_metadata(
if !coats.is_empty() {
reply["coats"] = json!(coats);
}
if request.raytrace.as_ref().is_some_and(|options| options.denoise) {
if let Some(options) = request.raytrace.as_ref().filter(|options| options.denoise) {
reply["denoising"] = json!({
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":3, "steps":[1,2,4],
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":options.denoise_settings.passes,
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
"diagnostics_requested":options.denoise_diagnostics,
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
"optical_policy":if options.denoise_settings.optical_filter {DENOISE_OPTICAL_POLICY} else {DENOISE_OPAQUE_ONLY_POLICY},
"guides":"deterministic_center_ray_albedo_normal_camera_relative_position_depth",
"bypass":"glass, sharp CPU mirror overrides, smooth metal/coat, emitter, background and partial coverage first hits",
"limitation":"biased spatial filter; first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
"bypass":if options.denoise_settings.optical_filter {"unsupported or ambiguous optical paths, emitter, background and partial coverage"} else {"all optical paths, emitter, background and partial coverage"},
"limitation":"biased spatial filter; optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
"raw_linear_accumulator_preserved":true,"raw_linear_exr_preserved":true,
"output_stage":"linear_before_tone_map", "work_units":"one pixel visit per pass, each with at most 25 taps",
"checkpoints":"every complete published pass uses the same filter"
@@ -714,16 +760,22 @@ fn prepare_progressive(
options.lighting = input.lighting;
options.tone_map = input.tone_map.unwrap_or_default();
options.denoise = input.denoise;
options.denoise_settings = input.denoise_settings;
options.denoise_diagnostics = input.denoise_diagnostics;
options.supersampling = input.supersampling.unwrap_or(1);
options.finishing = input.finishing;
options.backdrop = input.backdrop;
options.linear_exr = input.linear_exr;
options.aging = super::effects::resolve_aging(s, &scene, &input.aging, command)?;
options.nested_media = input.nested_media;
options.texture_filtering = input.texture_filtering;
options.metal_energy_compensation = input.metal_energy_compensation;
options.volume = input.volume;
options.caustics = input.caustics;
options.adaptive = input.adaptive;
options.transmission_samples = input.transmission_samples.unwrap_or(1);
options.validate().map_err(job_error)?;
solidcraft_render::job::supersampled_dimensions(settings.width, settings.height, options.supersampling).map_err(job_error)?;
if let Some(super::environment::Input::BuiltinHdr { id, .. }) = &input.environment
&& options.lighting.as_ref().is_some_and(|lighting| lighting.sun.is_some())
&& super::library::owns_sun(id, command)?
@@ -798,10 +850,17 @@ fn prepare_progressive(
height: settings.height,
background: settings.background,
transparent_background: input.transparent_background,
textures: super::textures::capture_with_resources(&scene, &input.raytrace.textures, Some(&s.doc.render.texture_resources), command)?,
textures: super::textures::capture_with_resources_and_filtering(
&scene,
&input.raytrace.textures,
Some(&s.doc.render.texture_resources),
command,
input.texture_filtering,
)?,
};
let refraction = input.progressive && scene.has_transmission();
let submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
let mut submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
submission.select_image_mode(settings.mode).map_err(job_error)?;
let request = submission.request();
let mut admission = submission.admission();
let retained_options_bytes = options.retained_bytes().map_err(job_error)?;
@@ -818,10 +877,14 @@ fn prepare_progressive(
}
if options.denoise {
reply["denoising"] = json!({
"method":"edge_aware_atrous", "passes":3, "steps":[1,2,4],
"method":"edge_aware_atrous", "passes":options.denoise_settings.passes,
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
"diagnostics_requested":options.denoise_diagnostics,
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
"optical_policy":if options.denoise_settings.optical_filter {DENOISE_OPTICAL_POLICY} else {DENOISE_OPAQUE_ONLY_POLICY},
"guides":"deterministic_center_ray_albedo_normal_position_depth",
"bypass":"glass, smooth metal/coat, emitter, background and partial coverage first hits",
"limitation":"first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
"bypass":if options.denoise_settings.optical_filter {"unsupported or ambiguous optical paths, emitter, background and partial coverage"} else {"all optical paths, emitter, background and partial coverage"},
"limitation":"optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
});
}
@@ -987,6 +1050,9 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
let frame = s.render_jobs.frame(job).map_err(job_error)?;
let bytes = match input.format {
ExportFormat::Png => frame.png(),
ExportFormat::DenoiseMask => frame
.denoise_mask_png()
.ok_or_else(|| bad("render.export", "this completed job has no guide mask; enable denoising diagnostics before rendering"))?,
ExportFormat::Exr => frame
.linear_exr()
.ok_or_else(|| bad("render.export", "this completed job has no raw linear master; enable retention before rendering"))?,
@@ -995,27 +1061,40 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
let frame_info = retyped("render.export", &frame.info)?;
let linear_master = frame.linear_master_info().map(|info| retyped("render.export", info)).transpose()?;
let presentation = frame.presentation().map(|info| retyped("render.export", info)).transpose()?;
let denoise_mask = frame.denoise_mask_info().map(|info| retyped("render.export", info)).transpose()?;
// Replace atomically without reading an arbitrarily large previous target into memory.
// Design saves retain their independent backup policy.
let result =
if input.overwrite { solidcraft_io::write_atomic(path, bytes, false) } else { solidcraft_io::safe_write::create_atomic(path, bytes) };
result.map_err(|e| bad("render.export", format!("could not export completed {}: {e}", input.format.extension())))?;
let exr = input.format == ExportFormat::Exr;
let reply = ImageExported {
job: Job(job),
bytes: bytes.len(),
path: input.path,
format: input.format,
encoding: if exr { "scene_linear_srgb_d65" } else { "srgb" },
alpha: if exr { "coverage_premultiplied" } else { "straight" },
encoding: match input.format {
ExportFormat::Png => "srgb",
ExportFormat::Exr => "scene_linear_srgb_d65",
ExportFormat::DenoiseMask => "categorical_srgb",
},
alpha: match input.format {
ExportFormat::Png => "straight",
ExportFormat::Exr => "coverage_premultiplied",
ExportFormat::DenoiseMask => "opaque",
},
frame: frame_info,
linear_master,
presentation,
denoise_mask,
param_warnings: Vec::new(),
};
super::typed_reply("render.export", reply)
}
fn denoise_steps(passes: u32) -> Vec<u32> {
(0..passes.min(3)).map(|pass| 1 << pass).collect()
}
fn validate_studio_weights(weights: Option<[f64; 3]>, command: &str) -> Result<Option<[f64; 3]>> {
if weights.is_some_and(|weights| weights.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) {
return Err(bad(command, "studio visibility weights must be finite in 0..1"));
@@ -784,7 +784,7 @@ fn invalid_saved_jobs_never_partially_replace_a_setup() {
save(&mut s, json!({"job":{"method":"cpu_raytrace","options":{"primary_samples":4}},"exposure_ev":2}));
for patch in [
json!({"width":800,"job":{"method":"cpu_raytrace","options":{"primary_samples":3}}}),
json!({"job":{"method":"gpu_path","options":{"samples":4097}}}),
json!({"job":{"method":"gpu_path","options":{"samples":65537}}}),
json!({"job":{"method":"cpu_raytrace","options":{"emissions":[]}}}),
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-opened.hdr"}}}}),
json!({"job":{"method":"raster"}}),
+13 -1
View File
@@ -115,11 +115,23 @@ pub(super) fn validate_targets(scene: &ProductScene, overrides: &[Override], com
pub(super) fn capture(scene: &ProductScene, overrides: &[Override], command: &str) -> Result<Option<Arc<RuntimeTextures>>> {
capture_with_resources(scene, overrides, None, command)
}
#[cfg(test)]
pub(super) fn capture_with_resources(
scene: &ProductScene,
overrides: &[Override],
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
command: &str,
) -> Result<Option<Arc<RuntimeTextures>>> {
capture_with_resources_and_filtering(scene, overrides, resources, command, false)
}
pub(super) fn capture_with_resources_and_filtering(
scene: &ProductScene,
overrides: &[Override],
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
command: &str,
filtering: bool,
) -> Result<Option<Arc<RuntimeTextures>>> {
if overrides.is_empty() {
return Ok(None);
@@ -164,7 +176,7 @@ pub(super) fn capture_with_resources(
flip_v: input.flip_v,
});
}
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?;
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?.with_filtering(filtering);
resources.validate_scene(scene).map_err(|e| bad(command, e.to_string()))?;
Ok(Some(Arc::new(resources)))
}
+2 -2
View File
@@ -21,7 +21,7 @@ fn bounded_dielectrics<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::R
impl<'de> serde::de::Visitor<'de> for Inputs {
type Value = Vec<Override>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("at most 32 disjoint dielectric body overrides")
f.write_str("at most 128 dielectric body overrides")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
let mut result = Vec::new();
@@ -74,7 +74,7 @@ impl TryFrom<String> for Boundary {
}
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
if overrides.len() > solidcraft_render::job::MAX_GPU_DIELECTRICS {
return Err(bad(command, "at most 32 runtime dielectric overrides are supported"));
return Err(bad(command, "at most 128 runtime dielectric overrides are supported"));
}
let mut ordinals = std::collections::BTreeSet::new();
for input in overrides {
+35 -8
View File
@@ -65,12 +65,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("settings")
.noundo()
.doc_only()
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job."),
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job. The UI separately limits new selections to 8192 per side, 33554432 output pixels and the selected rendering profile."),
CommandSpec::new("render.result.view", "View Render Image", result_view)
.icon("render_image")
.noundo()
.doc_only()
.params("view: model|image|fit|actual. Validated result canvas request; headless echo only, never starts a job."),
.params("view: model|image|fit|actual|denoise_mask. Guide-mask view requires a selected completed result captured with denoising diagnostics. Validated result canvas request; headless echo only, never starts a job."),
CommandSpec::new("render.result.close", "Close Render Image", close_result)
.icon("close")
.key("Escape")
@@ -275,8 +275,18 @@ struct ResultView {
}
fn result_view(s: &mut Session, params: &Value) -> Result<Value> {
let input: ResultView = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.view", e.to_string()))?;
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual") {
return Err(bad("render.result.view", "view must be model, image, fit or actual"));
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual" | "denoise_mask") {
return Err(bad("render.result.view", "view must be model, image, fit, actual or denoise_mask"));
}
if input.view == "denoise_mask" {
s.render_jobs.collect().map_err(crate::render_jobs::job_error)?;
let job = s.render_selected_result().ok_or_else(|| bad("render.result.view", "no completed result is selected"))?;
let frame = s.render_jobs.frame(job).map_err(crate::render_jobs::job_error)?;
if frame.denoise_mask_png().is_none() {
return Err(bad("render.result.view", "the selected result has no denoising guide mask"));
}
s.render_jobs.set_comparison(None);
return Ok(json!({"view":input.view,"job":job}));
}
if input.view == "model" {
s.render_jobs.set_comparison(None);
@@ -297,7 +307,16 @@ fn output_viewer_requests_are_validated_headless_echoes() {
let mut s = Session::default();
let document = s.doc.clone();
let before = (s.revision, s.undo.len(), s.evaluations);
for size in [json!({"width":1280,"height":720}), json!({"width":1920,"height":1080})] {
// Headless requests keep the established per-side range. They allocate no
// image; the UI and actual renderer independently enforce their envelopes.
for size in [
json!({"width":1280,"height":720}),
json!({"width":1920,"height":1080}),
json!({"width":7680,"height":4320}),
json!({"width":16384,"height":1}),
json!({"width":1,"height":16384}),
json!({"width":16384,"height":16384}),
] {
assert_eq!(s.execute("render.output.size", &size).unwrap(), size);
}
let custom = json!({"width":1920,"height":1080,"custom":true});
@@ -307,10 +326,18 @@ fn output_viewer_requests_are_validated_headless_echoes() {
let request = json!({"view":view});
assert_eq!(s.execute("render.result.view", &request).unwrap(), request);
}
assert!(s.execute("render.result.view", &json!({"view":"denoise_mask"})).is_err());
assert_eq!(s.execute("render.result.close", &json!({})).unwrap(), json!({"view":"model"}));
for request in
[json!(null), json!({}), json!({"width":0,"height":1}), json!({"width":u64::MAX,"height":1}), json!({"width":1,"height":1,"extra":true})]
{
for request in [
json!(null),
json!({}),
json!({"width":0,"height":1}),
json!({"width":1,"height":0}),
json!({"width":16385,"height":1}),
json!({"width":1,"height":16385}),
json!({"width":u64::MAX,"height":1}),
json!({"width":1,"height":1,"extra":true}),
] {
assert!(s.execute("render.output.size", &request).is_err());
}
for request in [json!(null), json!({"view":"unknown"}), json!({"view":"image","extra":true})] {
+3 -2
View File
@@ -1205,8 +1205,9 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
let text = if format == BendTableFormat::Json {
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
} else {
// Always quote text fields: body/rule names may include commas, quotes or newlines.
let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
// Always quote text fields: body/rule names may include commas, quotes or newlines, or start
// as a spreadsheet formula does.
let quote = solidcraft_doc::csv::quoted;
let mut csv =
"body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm,included_angle_deg\r\n"
.to_string();
+11
View File
@@ -2069,6 +2069,17 @@ fn a_flange_bent_inside_keeps_the_base_sketchs_arcs_in_the_flat_pattern_and_the_
}
}
/// A rule or body name that starts as a spreadsheet formula does is written as text (#12728).
#[test]
fn bend_table_writes_a_formula_like_name_as_text() {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "=A1+1", "thickness": 2, "bend_radius": 3, "k_factor": 0.4}));
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20}));
let text = run(&mut s, "sheet.export_bend_table", json!({}))["text"].as_str().unwrap_or_default().to_string();
assert!(text.lines().nth(1).is_some_and(|row| row.contains(",\"'=A1+1\",2.000000000,")), "{text}");
}
#[test]
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
let mut s = Session::default();
+2 -1
View File
@@ -206,10 +206,11 @@ fn child() {
}
// A clean exit whose journal cannot be removed: the directory is read-only.
"clean_read_only" => {
let sessions = dir.join("sessions");
// Only Unix makes the folder read-only here; elsewhere the session just ends.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let sessions = dir.join("sessions");
fs::set_permissions(&sessions, fs::Permissions::from_mode(0o500)).unwrap();
end_session();
fs::set_permissions(&sessions, fs::Permissions::from_mode(0o700)).unwrap();
+4 -1
View File
@@ -696,7 +696,10 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
Axis => {
if let Some(axis) = v.as_object() {
let mut axis = axis.clone();
if let Some(origin) = axis.get("origin").and_then(|o| m.make(o)) {
// An `{origin, dir}` axis without its origin passes through the world's,
// which the component may have moved.
let origin = axis.get("origin").cloned().or_else(|| axis.contains_key("dir").then(|| pt(Vec3::ZERO)));
if let Some(origin) = origin.and_then(|o| m.make(&o)) {
axis.insert("origin".into(), origin);
}
if let Some(dir) = axis.get("dir").and_then(vec3)
+32
View File
@@ -456,6 +456,38 @@ fn a_surface_revolve_about_a_picked_edge_of_a_moved_component() {
assert!(near(edge.0, line.0) && near(edge.1, line.1), "{edge:?} against {line:?}");
}
/// A revolve axis given only by its direction passes through the world origin in a moved
/// component, as one with `origin: [0, 0, 0]` does: solid and surface revolves, made and edited.
#[test]
fn an_axis_without_an_origin_passes_through_the_world_origin() {
let made = |command: &str, axis: Value| {
let mut s = moved_part();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Ring"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 0], "p1": [10, 5]}));
run(&mut s, "sketch.finish", json!({}));
let mut p = json!({"sketch": "Ring", "axis": axis, "name": "Turn"});
if command == "solid.revolve" {
p["operation"] = json!("new");
}
run(&mut s, command, p.clone());
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
let first = world_bbox(&s, &body);
// Edit Feature with the same axis given again.
run(&mut s, "timeline.redefine", json!({"feature": "Turn", "command": command, "params": p}));
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
(first, world_bbox(&s, &body))
};
for command in ["solid.revolve", "surface.revolve"] {
let (given, given_edit) = made(command, json!({"origin": [0, 0, 0], "dir": [0, 0, 1]}));
let (omitted, omitted_edit) = made(command, json!({"dir": [0, 0, 1]}));
assert!(near(given.0, omitted.0) && near(given.1, omitted.1), "{command}: {given:?} against {omitted:?}");
assert!(
near(given_edit.0, omitted_edit.0) && near(given_edit.1, omitted_edit.1),
"{command}, edited: {given_edit:?} against {omitted_edit:?}"
);
}
}
/// A new placement given to `timeline.redefine` is still a world point.
#[test]
fn a_new_primitive_placement_is_still_mapped_from_the_world() {
+41
View File
@@ -125,6 +125,47 @@ fn an_upstream_cut_that_splits_a_face_keeps_the_edge_that_bounded_it() {
assert!(solid_at(&s, [0.1, 15.0, 9.9]), "the left one is not");
}
/// Three pockets of a pattern open onto the block's front wall, and a channel at their depth
/// joins their floors into one face: the three edges between the wall and that floor share one
/// name, numbered by where they lie, and a chamfer keeps all three. With the channel shallower,
/// each pocket has its own floor again, and only the first pocket's edge lies between the wall
/// and the floor the names name. The chamfer asks for its edges again instead of chamfering
/// that one edge for all three (#13332); with the floors joined again, it finds all three.
#[test]
fn edges_numbered_between_two_faces_are_not_all_taken_for_the_one_left_there() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "channel_floor", "expression": "5 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "PocketSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3, -5], "p1": [9, 6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "PocketSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Pocket"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Pocket"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 12, "name": "Row"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ChannelSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3, 3], "p1": [33, 9]}));
run(&mut s, "sketch.finish", json!({}));
let channel = json!({"sketch": "ChannelSketch", "distance": 12, "start_offset": "channel_floor", "operation": "cut", "name": "Channel"});
run(&mut s, "solid.extrude", channel);
run(&mut s, "solid.chamfer", json!({"edges": [[6, 0, 5], [18, 0, 5], [30, 0, 5]], "distance": 1, "name": "Chamfer"}));
let (block, pocket) = (id(&s, "Block"), id(&s, "Pocket"));
let chamfer = s.doc.find_feature("Chamfer").cloned().unwrap();
let mut stored = chamfer.edge_names.clone();
stored.sort();
assert_eq!(stored, (1..=3).map(|k| format!("F{block}:side:l1|F{pocket}:top~{k}")).collect::<Vec<_>>());
// Inside the block just below each pocket's floor, at the front: cut away by a chamfer there.
let chamfered = |s: &Session| [6.0, 18.0, 30.0].map(|x| !solid_at(s, [x, 0.2, 4.8]));
assert_eq!(chamfered(&s), [true; 3]);
change(&mut s, "channel_floor", "6 mm");
let asked = reselect(&s, "Chamfer");
assert!(asked.as_ref().is_some_and(|name| chamfer.edge_names.contains(name)), "{asked:?}, {:?}", error(&s, "Chamfer"));
assert_eq!(chamfered(&s), [false; 3], "no pocket's edge is chamfered for another's");
change(&mut s, "channel_floor", "5 mm");
assert_eq!(error(&s, "Chamfer"), None);
assert_eq!(chamfered(&s), [true; 3]);
}
#[test]
fn a_blend_follows_its_named_edge_to_a_moved_lid_instead_of_the_body_below() {
for (command, size, removed) in [("solid.fillet", "radius", (4.0 - std::f64::consts::PI) * 5.0), ("solid.chamfer", "distance", 2.0 * 5.0)] {
@@ -247,10 +247,11 @@ fn first(claim: Claim, out: &mut Prepared) -> Result<()> {
// No normalized Arc<Value> remains alongside this arena. The exact original
// Batch input belongs only to this first Prepared command metadata owner,
// never to the persistent Plan, and retires on the same worker after finish.
let plan = Plan { ticket: claim.ticket, payload, rows, scope: claim.scope, next: 0, lease };
let mut plan = Plan { ticket: claim.ticket, payload, rows, scope: claim.scope, next: 0, lease };
if plan.actual_bytes()? != plan.lease.bytes() {
return Err(bad("batch arena actual backing disagrees with reservation"));
}
plan.lease.settle().map_err(crate::render_jobs::job_error)?;
out.plan = Some(plan);
Ok(())
}
+344
View File
@@ -0,0 +1,344 @@
//! Contract for `solid.align` in `cmd/features_more.rs` (#13314). Align adds a feature and returns
//! `add_feature`'s reply, a [`super::features::FeatureResult`].
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler reads
//! it and fails with the handler's message. Whether a body, a plane, a sketch or a joint origin
//! exists, whether a pick lies on a planar face, and which combinations of keys go together, stay
//! the handler's to check.
//!
//! Align reads some keys only in some cases, and the contract reads them in the same cases:
//!
//! - a side (`from` or `to`) that has a plane or a frame (`from_plane`, `from_frame`) is worked
//! out from that, and its point and face keys are not read (a call that gives both is refused by
//! the handler as a whole);
//! - otherwise a face that is a point (`from_face`) wins, and `from` is not read at all;
//! - otherwise `from` is read: `"origin"`, `{snap?, at}` or `[x, y, z]`.
//!
//! So the type is written by hand, in the handler's order, and a call that works today gets no
//! warning for a key the handler does not read. Where the handler reads a key of the wrong type as
//! if it were left out (`flip`, `angle`, a `from_face` that is not a point, a `snap` that is not
//! text), the contract refuses it: that is what warn and strict are for. `bodies` is optional in
//! the type and required in the schema, and so is one of `from`, `from_face`, `from_plane` and
//! `from_frame` (and the same for `to`), so that the handler's own message is what a caller sees.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::{Map, Value, json};
use super::check::whole;
use super::config::NameList;
use super::joints::JointOriginArg;
use super::motion::require;
use super::types::{AngleExpr, FeatureCommon, PlaneParam, Point3, SketchRef, StringList, feature_common};
use crate::cmd::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, sketch_frame_id, sketch_key};
use crate::params::{self, MAX_STRING_LIST};
/// The handler's message for `bodies` left out, `null` or empty.
const BODIES_MESSAGE: &str = "`bodies` must list the bodies to move";
/// The kinds of snap point `from` and `to` take.
const SNAPS: [&str; 4] = ["vertex", "edge_mid", "face_center", "circle_center"];
/// The `bodies` schema: one name, or a list of 1 to 10 000.
fn bodies_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The bodies to move, by name: one, or a list of 1 to 10 000.",
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": MAX_STRING_LIST}
]
})
}
/// A point of an Align side: `"origin"`, a snap `{snap?, at}` or `[x, y, z]`. Only for the schema:
/// whether it is read at all depends on the side's other keys, so [`AlignParams`] reads it.
struct SidePointSchema;
impl JsonSchema for SidePointSchema {
fn schema_name() -> Cow<'static, str> {
"AlignPoint".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let c = json!({"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9});
json_schema!({
"description": "A point: [x, y, z] in mm, the text \"origin\" (any case), or {\"snap\"?, \"at\"}: the nearest vertex, edge midpoint, planar face centre or circular edge centre of the model to the point `at`. {\"x\", \"y\", \"z\"} is not a point here.",
"anyOf": [
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
{"type": "string", "pattern": "^[Oo][Rr][Ii][Gg][Ii][Nn]$"},
{
"type": "object",
"properties": {
"snap": {"description": "What to snap to. Default: vertex.", "type": "string", "enum": SNAPS},
"at": {"description": "The point to snap near: [x, y, z] or {\"x\", \"y\", \"z\"}, in mm.", "anyOf": [
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
{"type": "object", "properties": {"x": c, "y": c, "z": c}, "required": ["x", "y", "z"]}
]}
},
"required": ["at"]
}
]
})
}
}
/// A frame of an Align side: a joint origin as `joint.create` takes one, or `{sketch, point|curve}`.
/// Only for the schema; [`AlignParams`] reads it with [`frame_problem`].
struct FrameSchema;
impl JsonSchema for FrameSchema {
fn schema_name() -> Cow<'static, str> {
"AlignFrame".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let sketch = generator.subschema_for::<SketchRef>();
let origin = generator.subschema_for::<JointOriginArg>();
let id = json!({"type": "string", "minLength": 1, "maxLength": MAX_SKETCH_ID});
json_schema!({
"description": "A frame that is found again whenever the design changes, so the bodies follow it: a sketch point, or the centre of a sketch circle, arc or ellipse, as {\"sketch\", \"point\" | \"curve\"} (z along the sketch's normal); or a joint origin as joint.create takes one: {face | circle | edge | point: [x, y, z]}, a face's name, or the name of a joint origin. On the bodies' own component, which must hold all of them and be placed once.",
"anyOf": [
{
"type": "object",
"properties": {
"sketch": sketch,
"point": {"description": "A sketch point's id: 1 to 256 characters. Give this or `curve`.", "allOf": [id]},
"curve": {"description": "A sketch circle's, arc's or ellipse's id: 1 to 256 characters. Give this or `point`.", "allOf": [id]}
},
"required": ["sketch"],
"anyOf": [
{"required": ["point"], "not": {"required": ["curve"]}},
{"required": ["curve"], "not": {"required": ["point"]}}
]
},
{"allOf": [origin], "not": {"type": "object", "required": ["sketch"]}}
]
})
}
}
/// `axes`: three booleans, at least one true. Only for the schema.
struct AxesSchema;
impl JsonSchema for AxesSchema {
fn schema_name() -> Cow<'static, str> {
"AlignAxes".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Which world axes the move follows: [x, y, z] booleans, at least one true.",
"type": "array",
"items": {"type": "boolean"},
"minItems": 3,
"maxItems": 3,
"contains": {"const": true}
})
}
}
/// `bodies` is required, and so is one key for each side.
fn align_schema(schema: &mut Schema) {
require(schema, "bodies");
// Each side needs one of its four keys; the handler says which, in its own words.
let side = |keys: [&str; 4]| json!({"anyOf": keys.iter().map(|k| json!({"required": [k]})).collect::<Vec<_>>()});
let sides = [side(["from", "from_face", "from_plane", "from_frame"]), side(["to", "to_face", "to_plane", "to_frame"])];
match schema.get_mut("allOf") {
Some(Value::Array(all)) => all.extend(sides),
_ => {
schema.insert("allOf".into(), Value::Array(sides.into()));
}
}
}
/// Move bodies so that a point, face, plane or frame on them meets another one, as a feature in
/// the timeline (one undo step). Each side is one of: a point (`from`, `to`), a planar face
/// picked by a point on it (`from_face`, `to_face`), a plane (`from_plane`, `to_plane`) or a frame
/// (`from_frame`, `to_frame`); give one key for each side. A plane or a frame is worked out again
/// whenever the design changes, so the bodies follow it. Faces, planes and frames also turn the
/// bodies so that they meet (the first side's face against the second's, or with `flip` side by
/// side); a point onto a plane moves along the plane's normal. `axes` keeps the move to some
/// world axes and only goes with two points. Align makes no body, so `body_name` and `body_names`
/// do nothing here (as for Remove), and `participants` changes nothing in the result.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = align_schema)]
pub struct AlignParams {
// Optional in the type and required in the schema (`align_schema`): left out, `null` or
// empty, the handler refuses the call with its own message.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "bodies_schema")]
pub bodies: Option<NameList>,
/// Where the move starts, on the bodies. Not read when `from_face` is a point; not with
/// `from_plane` or `from_frame` (the handler refuses both).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SidePointSchema")]
pub from: Option<Value>,
/// A point on a planar face of the bodies to start from: the bodies turn so that the face
/// meets the target's. When it is a point, `from` is not read. Not with `from_plane` or
/// `from_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub from_face: Option<Value>,
/// A plane to start from, kept as a plane so the bodies follow it when it moves; the bodies
/// turn so that it meets the target. Not with `from`, `from_face` or `from_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaneParam")]
pub from_plane: Option<Value>,
/// A frame to start from, in place of the point, face or plane; the bodies turn so that it
/// meets the target. Not with `from`, `from_face` or `from_plane`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "FrameSchema")]
pub from_frame: Option<Value>,
/// Where the bodies go. Not read when `to_face` is a point; not with `to_plane` or
/// `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SidePointSchema")]
pub to: Option<Value>,
/// A point on a planar face to go to. When it is a point, `to` is not read. Not with
/// `to_plane` or `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub to_face: Option<Value>,
/// A plane to go to, which may move later (the bodies follow); a point or a face goes onto
/// it along its normal. Not with `to`, `to_face` or `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaneParam")]
pub to_plane: Option<Value>,
/// A frame to go to, in place of the point, face or plane. It must not be on a body that
/// moves. Not with `to`, `to_face` or `to_plane`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "FrameSchema")]
pub to_frame: Option<Value>,
/// With faces, planes or frames: true turns the bodies so that the two normals point the same
/// way, instead of facing each other. Default: false.
#[serde(default)]
pub flip: Option<bool>,
/// Which world axes the offset moves along, with a point on each side: [x, y, z] booleans,
/// at least one true. Default: all three. Refused with a face, plane or frame.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "AxesSchema")]
pub axes: Option<[bool; 3]>,
/// A turn about the target's direction (a face's normal, a plane's normal or a frame's z)
/// after the move, in degrees or an expression. Needs a face, a plane or a frame as the
/// target. Default: none.
#[serde(default)]
pub angle: Option<AngleExpr>,
#[serde(flatten)]
pub common: FeatureCommon,
}
/// `bodies` as the handler reads it: names, at least one; `null` and a missing key are none.
fn bodies(m: &Map<String, Value>) -> Result<NameList, String> {
let names = match m.get("bodies") {
None | Some(Value::Null) => NameList::default(),
// Each of its messages starts with "must": the key goes in front, as `string_list` has it.
Some(v) => NameList::deserialize(v).map_err(|e| whole(format!("`bodies` {e}")))?,
};
if names.0.is_empty() { Err(whole(BODIES_MESSAGE)) } else { Ok(names) }
}
/// `key`, a plane, when it is given (`null` included: the handler reads it as a plane and
/// refuses it).
fn plane(m: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
// The reader's own message is a whole sentence, marked as such.
m.get(key).map(|v| PlaneParam::deserialize(v).map(|p| p.0).map_err(|e| e.to_string())).transpose()
}
/// What is wrong with the frame `v` of `key` (`from_frame`), as the handler says it, marked
/// whole, or `None`.
fn frame_problem(v: &Value, key: &str) -> Option<String> {
if let Some(sketch) = v.get("sketch") {
if sketch_key(Some(sketch)).is_none() {
return Some(whole(format!("`{key}` must be a sketch id or name")));
}
return match sketch_frame_id(v) {
Some(SketchFrameId::Point(_) | SketchFrameId::Curve(_)) => None,
None => Some(whole(format!("`{key}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
};
}
// A joint origin, which the handler reads as `joint.create` reads one.
JointOriginArg::deserialize(v).err().map(|e| e.to_string())
}
/// `<key>_frame`, when it is given and not `null`.
fn frame(m: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
let frame_key = format!("{key}_frame");
match m.get(&frame_key) {
None | Some(Value::Null) => Ok(None),
// The handler refuses a frame given beside the side's other keys before it reads the frame.
Some(v) if [key.to_string(), format!("{key}_face"), format!("{key}_plane")].iter().any(|k| m.contains_key(k)) => Ok(Some(v.clone())),
Some(v) => frame_problem(v, &frame_key).map_or_else(|| Ok(Some(v.clone())), Err),
}
}
/// The point and face of the side `pk` (`from`), read as the handler reads them: not at all when
/// the side has a plane or a frame (`held`); the face if it is a point, and then `from` is not
/// read; else `from`.
fn side(m: &Map<String, Value>, pk: &str, held: bool) -> Result<(Option<Value>, Option<Value>), String> {
if held {
return Ok((None, None));
}
let fk = format!("{pk}_face");
match m.get(&fk) {
None | Some(Value::Null) => {}
Some(v) if params::vec3(v).is_some() => return Ok((None, Some(v.clone()))),
Some(_) => return Err(whole(format!("`{fk}` must be a point [x, y, z] of numbers"))),
}
let v = m.get(pk);
match crate::cmd::side_point(v, pk, &fk).map_err(whole)? {
SidePoint::Snap { .. } => {
// The handler reads a `snap` that is not text as a vertex; the word is its to look up.
if v.and_then(|o| o.get("snap")).is_some_and(|s| !s.is_null() && !s.is_string()) {
return Err(whole(format!("`{pk}.snap` must be {}", SNAPS.join(", "))));
}
}
SidePoint::Origin | SidePoint::Point(_) => {}
}
Ok((v.cloned(), None))
}
impl<'de> Deserialize<'de> for AlignParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = Map::<String, Value>::deserialize(d)?;
let fail = D::Error::custom;
// In the handler's order: the bodies, the planes, the frames, the two sides' points, the
// angle and the axes; then `add_feature`'s keys.
let bodies = bodies(&m).map_err(fail)?;
let (from_plane, to_plane) = (plane(&m, "from_plane").map_err(fail)?, plane(&m, "to_plane").map_err(fail)?);
let (from_frame, to_frame) = (frame(&m, "from").map_err(fail)?, frame(&m, "to").map_err(fail)?);
let (from, from_face) = side(&m, "from", from_plane.is_some() || from_frame.is_some()).map_err(fail)?;
let (to, to_face) = side(&m, "to", to_plane.is_some() || to_frame.is_some()).map_err(fail)?;
let angle = match m.get("angle") {
None | Some(Value::Null) => None,
Some(v) => Some(AngleExpr::deserialize(v).map_err(|e| fail(whole(format!("`angle` {e}"))))?),
};
let axes = match m.get("axes") {
None | Some(Value::Null) => None,
Some(v) => Some(axes_mask(v).map_err(|why| fail(whole(why)))?),
};
let flip = match m.get("flip") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => return Err(fail(whole("`flip` must be true or false"))),
};
// `add_feature` reads `body_names` and `participants` as `string_list` does, whose messages
// are `NameList`'s, not `FeatureCommon`'s; they are read here, and the rest go to it.
let mut m = m;
let mut lists = Vec::new();
for key in ["participants", "body_names"] {
if let Some(v) = m.remove(key).filter(|v| !v.is_null()) {
lists.push(StringList(NameList::deserialize(v).map_err(|e| fail(whole(format!("`{key}` {e}"))))?.0));
} else {
lists.push(StringList::default());
}
}
let mut common = feature_common::<_, FeatureCommon>(Value::Object(m)).map_err(|e| fail(e.to_string()))?;
let (body_names, participants) = (lists.pop().filter(|l| !l.0.is_empty()), lists.pop().filter(|l| !l.0.is_empty()));
(common.body_names, common.participants) = (body_names, participants);
Ok(AlignParams { bodies: Some(bodies), from, from_face, from_plane, from_frame, to, to_face, to_plane, to_frame, flip, axes, angle, common })
}
}
#[cfg(test)]
#[path = "align_tests.rs"]
mod tests;
+329
View File
@@ -0,0 +1,329 @@
use serde_json::{Value, json};
use super::AlignParams;
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
/// Box `A` (10 x 10 x 10 at the origin) and `B` (10 x 20 x 10 at (50, 0, 20)), and sketch `S` on
/// XY with a circle `ring` at (0, 5) and a point `p` at (20, 0).
fn scene(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"})),
("solid.box", json!({"length": 10, "width": 20, "height": 10, "corner": [50, 0, 20], "body_name": "B", "operation": "new"})),
("sketch.create", json!({"plane": "XY", "name": "S"})),
("sketch.circle.center", json!({"center": [0, 5], "radius": 3, "id": "ring"})),
("sketch.point", json!({"point": [20, 0], "id": "p"})),
("sketch.finish", json!({})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
}
s
}
/// `base` for body `B` with `extra`'s keys added.
fn call(extra: Value) -> Value {
let mut p = json!({"bodies": ["B"]});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
}
fn messages(p: &Value) -> Vec<String> {
issues("solid.align", p).into_iter().map(|i| i.message).collect()
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<AlignParams>(),
&[
"angle",
"axes",
"bodies",
"body_name",
"body_names",
"flip",
"from",
"from_face",
"from_frame",
"from_plane",
"name",
"participants",
"to",
"to_face",
"to_frame",
"to_plane",
],
);
let schema = input_schema_for::<AlignParams>();
assert_eq!(schema.as_value()["required"], json!(["bodies"]));
}
/// Calls that fail today get the same message in strict, on a design with the bodies, a sketch
/// and its geometry (the handler's first check is that the bodies exist).
#[test]
fn strict_keeps_todays_errors() {
let long = "q".repeat(300);
let cases = [
json!({}),
json!({"bodies": null, "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": [], "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": 5, "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": ["B", 5], "from": [0, 0, 0], "to": [1, 1, 1]}),
call(json!({})),
call(json!({"from": [0, 0, 0]})),
call(json!({"to": [0, 0, 0]})),
call(json!({"from": [0, 0], "to": [0, 0, 0]})),
call(json!({"from": null, "to": [0, 0, 0]})),
call(json!({"from": "nowhere", "to": [0, 0, 0]})),
call(json!({"from": {"x": 1, "y": 2, "z": 3}, "to": [0, 0, 0]})),
call(json!({"from": {"snap": "vertex"}, "to": [0, 0, 0]})),
call(json!({"from": {"at": "here"}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to": {"at": [1, 2]}})),
call(json!({"from": [0, 0, 0], "to": "centre"})),
call(json!({"from": "origin", "to_face": null})),
call(json!({"from_plane": null, "to": [0, 0, 0]})),
call(json!({"from_plane": true, "to": [0, 0, 0]})),
call(json!({"from_plane": -1, "to": [0, 0, 0]})),
call(json!({"from_plane": {"origin": [0, 0]}, "to": [0, 0, 0]})),
call(json!({"from_plane": {}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to_plane": [1]})),
call(json!({"from_frame": 5, "to": [0, 0, 0]})),
// A frame beside the side's other keys is refused as "not both" before it is read.
call(json!({"from_frame": 5, "from": [0, 0, 0], "to": [1, 1, 1]})),
call(json!({"from_plane": "XY", "from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {}}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": true, "point": "p"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": null, "point": "p"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "p", "curve": "ring"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": ""}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": long}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to_frame": {"sketch": "S", "point": 5}})),
call(json!({"from": [0, 0, 0], "to_frame": {"face": [1, 2]}})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": "x"})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [true, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [true, 1, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [false, false, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": 5})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": ["Nope"]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": ["B", false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": 5, "body_names": 6})),
call(json!({"from": [0], "to": [1]})),
call(json!({"from_plane": 7.5, "to_plane": true})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "body_names": [7]})),
];
for p in &cases {
let error = |level: ParamCheck| scene(level).execute("solid.align", p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
// The ones that fail before any body is looked up, with the shared helper's session.
assert_strict_keeps_todays_errors(&[
("solid.align", json!({})),
("solid.align", json!({"bodies": []})),
("solid.align", json!({"bodies": 5})),
("solid.align", json!({"bodies": [1]})),
]);
}
/// A side that has a plane or a frame is read from that, and one that has a face that is a point
/// is read from the face: the keys the handler does not read in that case are not checked, so a
/// call that works today gets no warning.
#[test]
fn keys_the_handler_does_not_read_are_not_checked() {
let calls = [
// `from` is not read when `from_face` is a point.
call(json!({"from_face": [55, 10, 20], "from": "garbage", "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "from": null, "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "from": {"snap": 5}, "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 0], "to": 17})),
// A null face is a missing one.
call(json!({"from_face": null, "from": [50, 0, 20], "to": [0, 0, 0]})),
// A null frame is a missing one; so is a face that is null beside a frame.
call(json!({"from_frame": null, "from": [50, 0, 20], "to": "origin"})),
call(json!({"from": [50, 0, 20], "to_frame": null, "to": [1, 2, 3]})),
// A snap with no `snap` snaps to a vertex; the object's other keys are not read.
call(json!({"from": {"at": [50, 0, 20], "note": 1}, "to": {"snap": null, "at": [0, 0, 0]}})),
// `axes` and `angle` may be `null`.
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": null, "angle": null, "flip": null})),
];
for p in &calls {
assert!(scene(ParamCheck::Off).execute("solid.align", p).is_ok(), "{p} works today");
assert!(messages(p).is_empty(), "{p}: {:?}", messages(p));
let reply = scene(ParamCheck::Warn).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(scene(ParamCheck::Strict).execute("solid.align", p).is_ok(), "{p}");
}
// With a plane or a frame the point and face are not read: the handler refuses the call, in
// its own words, and the contract leaves that to it.
for p in [
call(json!({"from_plane": "XY", "from": "garbage", "to": [0, 0, 0]})),
call(json!({"from_plane": "XY", "from_face": "garbage", "to": [0, 0, 0]})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "from": 5, "to": [0, 0, 0]})),
] {
assert!(messages(&p).is_empty(), "{p}: {:?}", messages(&p));
let error = |level: ParamCheck| scene(level).execute("solid.align", &p).err().map(|e| e.to_string());
assert!(error(ParamCheck::Off).is_some_and(|e| e.contains("not both")), "{p}");
assert_eq!(error(ParamCheck::Strict), error(ParamCheck::Off), "{p}");
}
}
/// Keys of the wrong type that the handler reads as left out are refused by the contract: in Warn
/// the call still works, with a warning naming the key.
#[test]
fn the_contract_refuses_what_the_handler_ignores() {
let cases = [
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "flip": "yes"})), "flip"),
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "angle": true})), "angle"),
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "angle": []})), "angle"),
(call(json!({"from_face": "junk", "from": [50, 0, 20], "to": [0, 0, 0]})), "from_face"),
(call(json!({"from": {"snap": 5, "at": [50, 0, 20]}, "to": [0, 0, 0]})), "from.snap"),
];
for (p, key) in cases {
assert!(scene(ParamCheck::Off).execute("solid.align", &p).is_ok(), "{p} works today");
let warned = scene(ParamCheck::Warn).execute("solid.align", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(warned["param_warnings"].to_string().contains(key), "{p}: {warned}");
let error = scene(ParamCheck::Strict).execute("solid.align", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains(&format!("`{key}`")), "{p}: {error}");
}
}
/// The messages the check gives, which are the handler's.
#[test]
fn the_check_names_what_is_wrong_with_a_key() {
let go = |extra: Value| messages(&call(extra));
assert_eq!(messages(&json!({})), ["`bodies` must list the bodies to move"]);
assert_eq!(messages(&json!({"bodies": [5]})), ["`bodies` must list names; the entry 1 is 5"]);
assert_eq!(go(json!({})), ["give `from`, `from_face` or `from_plane` ([x, y, z], \"origin\", or a plane)"]);
assert_eq!(go(json!({"from": "origin"})), ["give `to`, `to_face` or `to_plane` ([x, y, z], \"origin\", or a plane)"]);
assert_eq!(go(json!({"from": "ORIGIN", "to": {"snap": "vertex"}})), ["`to.at` must be [x, y, z]"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "axes": [false, false, false]})), ["`axes` must enable at least one of X, Y and Z"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "axes": [true]})), ["`axes` must be [x, y, z] booleans"]);
assert_eq!(
go(json!({"from": "origin", "to": "origin", "angle": "x".repeat(5000)})),
["`angle` must be an expression of at most 4096 characters"]
);
assert_eq!(go(json!({"from": "origin", "to": "origin", "participants": 5})), ["`participants` must be a list of names; it is 5"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "bodies2": 1})).len(), 1);
assert_eq!(go(json!({"from": "origin", "to": "origin", "flipp": true})), ["unknown key `flipp`; did you mean `flip`?"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "flip": 1})), ["`flip` must be true or false"]);
}
/// The schema takes what the handler takes where the keys alone decide it. Each call is checked
/// against the schema and against the handler, with the check off.
#[test]
fn the_schema_takes_what_the_handler_takes() {
let schema = input_schema_for::<AlignParams>();
let schema = schema.as_value();
let ring = json!({"sketch": "S", "curve": "ring"});
let calls = [
call(json!({"from": [50, 0, 20], "to": [0, 0, 0]})),
call(json!({"from": "Origin", "to": {"snap": "vertex", "at": [0, 0, 0]}})),
call(json!({"from": {"at": {"x": 50, "y": 0, "z": 20}}, "to": [0, 0, 0], "axes": [false, false, true]})),
call(json!({"from_face": [55, 10, 20], "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 0], "flip": true, "angle": "30 deg"})),
call(json!({"from_plane": {"origin": [0, 0, 5], "normal": [0, 0, 1]}, "to": [0, 0, 7]})),
call(json!({"from_face": {"x": 55, "y": 10, "z": 20}, "to_plane": "XY"})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {"origin": [55, 10, 20], "z": [0, 0, -1]}}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5]})),
json!({"bodies": "B", "from": [50, 0, 20], "to": [0, 0, 0], "name": "Move", "participants": ["B"]}),
json!({}),
json!({"bodies": ["B"]}),
json!({"bodies": [], "from": [50, 0, 20], "to": [0, 0, 0]}),
call(json!({"from": [50, 0, 20]})),
call(json!({"to": [50, 0, 20]})),
call(json!({"from": [50, 0], "to": [0, 0, 0]})),
call(json!({"from": {"x": 50, "y": 0, "z": 20}, "to": [0, 0, 0]})),
call(json!({"from": {"snap": "vertex"}, "to": [0, 0, 0]})),
call(json!({"from_plane": 7.5, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "p", "curve": "ring"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": ""}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "q".repeat(257)}, "to": [0, 0, 0]})),
call(json!({"from_frame": 5, "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": [true]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "flip": "yes"})),
];
for p in &calls {
// The schema takes a call when the handler does and the check has nothing to say (a
// `flip` that is not true or false runs today, and is a warning).
let takes = scene(ParamCheck::Off).execute("solid.align", p).is_ok();
assert_eq!(conforms(schema, schema, p), takes && messages(p).is_empty(), "{p}: {:?}", messages(p));
}
}
/// Each form of the command, in Warn: a reply that matches the output schema and carries no
/// warning, since Warn runs a call the contract refuses as well. The same calls pass strict.
#[test]
fn replies_match_the_output_schema() {
let ring = json!({"sketch": "S", "curve": "ring"});
let calls = [
call(json!({"from": [50, 0, 20], "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": [true, false, true]})),
call(json!({"from": "ORIGIN", "to": {"snap": "vertex", "at": [10, 20, 30]}})),
call(json!({"from": {"snap": "edge_mid", "at": [50, 0, 25]}, "to": {"at": [0, 0, 0]}})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 10]})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 10], "flip": true, "angle": "30 deg"})),
call(json!({"from_face": {"x": 55, "y": 10, "z": 20}, "to_plane": "XY"})),
call(json!({"from_plane": {"origin": [0, 0, 5], "normal": [0, 0, 1]}, "to": [0, 0, 7]})),
call(json!({"from_plane": "XY", "to_plane": {"origin": [0, 0, 5], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5], "flip": true})),
call(json!({"from_frame": {"point": [55, 10, 20]}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {"origin": [55, 10, 20], "z": [0, 0, -1]}}, "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to_frame": {"sketch": "S", "point": "p"}})),
json!({"bodies": "B", "from": [50, 0, 20], "to": [0, 0, 0], "name": "Move B", "participants": ["B", "A"]}),
json!({"bodies": ["A", "B"], "from": [0, 0, 0], "to": [0, 0, 5], "body_name": "unused", "participants": "*"}),
];
let schema = output_schema_for::<FeatureResult>();
for p in &calls {
let reply = scene(ParamCheck::Warn).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{p}: {reply}");
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(messages(p).is_empty(), "{p}: {:?}", messages(p));
let strict = scene(ParamCheck::Strict).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
// The replies are the same but for the time it took.
let same = |mut r: Value| {
r.as_object_mut().map(|o| o.remove("ms"));
r
};
let off = scene(ParamCheck::Off).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert_eq!(same(strict), same(reply.clone()), "{p}");
assert_eq!(same(off), same(reply), "{p}");
}
// A misspelt key still runs in Warn, and says so, with a reply that matches.
let p = call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axis": [true, true, true]}));
let warned = scene(ParamCheck::Warn).execute("solid.align", &p).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `axis`; did you mean `axes`?"]), "{warned}");
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
}
/// The patterns and schemas the tests above can't read (`conforms` skips patterns and `contains`).
#[test]
fn the_schema_text_is_pinned() {
let schema = input_schema_for::<AlignParams>();
let all = schema.as_value().to_string();
assert!(all.contains(r#""pattern":"^[Oo][Rr][Ii][Gg][Ii][Nn]$""#), "{all}");
assert!(all.contains(r#""contains":{"const":true}"#), "{all}");
assert!(all.contains(r#""maxLength":256"#), "{all}");
assert!(schema.as_value()["properties"]["from"]["description"].is_string());
for key in ["from", "to"] {
let any = format!(
r#"{{"anyOf":[{{"required":["{key}"]}},{{"required":["{key}_face"]}},{{"required":["{key}_plane"]}},{{"required":["{key}_frame"]}}]}}"#
);
assert!(all.contains(&any), "{key}: {all}");
}
// `origin` in any case is what the handler reads, and nothing around it.
let origin = |t: &str| scene(ParamCheck::Off).execute("solid.align", &call(json!({"from": t, "to": [0, 0, 0]}))).is_ok();
assert!(origin("origin") && origin("OrIgIn") && !origin(" origin") && !origin("origin\n"));
}
+11 -1
View File
@@ -19,6 +19,15 @@ pub struct AssistantParams {}
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssistantResult {}
/// Command search is UI-owned and takes no parameters.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PaletteParams {}
/// Headless sessions refuse to open command search and produce no result.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PaletteResult {}
/// `edit.repeat` takes no parameters: it starts the last tool with its defaults.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -1519,7 +1528,7 @@ mod tests {
use super::{
AssistantParams, CheckpointParams, CheckpointResult, FeatureKeys, FeatureRef, FeatureSelection, FolderId, FolderIds, MarkerPosition, NewName,
RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
PaletteParams, RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult, UndoParams, UndoResult,
@@ -1537,6 +1546,7 @@ mod tests {
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<RepeatParams>(), &[]);
assert_keys(input_schema_for::<AssistantParams>(), &[]);
assert_keys(input_schema_for::<PaletteParams>(), &[]);
assert_keys(input_schema_for::<UndoParams>(), &[]);
assert_keys(input_schema_for::<RedoParams>(), &[]);
assert_keys(input_schema_for::<CheckpointParams>(), &[]);
+73
View File
@@ -18,6 +18,7 @@ use super::types::{
OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint,
TouchingParam, feature_common, present,
};
use super::types::{RevolveAxis, RevolveConventionParam, RevolveDirection, RevolveToPoint};
use crate::params::MAX_STRING_LIST;
/// Add a box primitive.
@@ -181,6 +182,78 @@ pub struct ExtrudeParams {
pub thickness2: Option<LengthExpr>,
}
/// Revolve sketch profiles (or a planar body face) about an axis into a solid.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RevolveParams {
/// The axis to turn about.
pub axis: RevolveAxis,
/// How far to turn: more than 0 and at most 360 degrees. With `angle2`, the angle on the
/// first side, the two adding up to the turn; with `direction: symmetric`, the whole turn,
/// split either side. Default: 360 deg. Not used when `to` is given, though it must still
/// evaluate.
#[serde(default)]
pub angle: Option<AngleExpr>,
/// Two Sides: the angle on the other side, turned back from the profile. Not used when `to`
/// is given; used instead of `direction: symmetric` when both are given.
#[serde(default)]
pub angle2: Option<AngleExpr>,
/// `symmetric` splits the angle evenly either side of the profile, whichever way the axis
/// points. The handler ignores every other value.
#[serde(default)]
pub direction: Option<RevolveDirection>,
/// When true, the same as `direction: symmetric`.
#[serde(default)]
pub symmetric: Option<bool>,
/// Where the angle starts. Default: sketch_normal.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "RevolveConventionParam")]
pub convention: Option<RevolveConventionParam>,
/// Turn until this face, vertex or point (To Object). Takes the place of `angle`, `angle2`
/// and `direction`.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "RevolveToPoint")]
pub to: Option<RevolveToPoint>,
/// The sketch to revolve, by id or name. Default: the active sketch, else the last one.
/// Ignored when `face` is given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "IdOrName")]
pub sketch: Option<IdOrName>,
/// Which of the sketch's profiles to revolve. Default: all of them. Ignored when `face` is
/// given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ProfilesParam")]
pub profiles: Option<ProfilesParam>,
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
#[serde(default)]
pub exact_regions: Option<bool>,
/// A point on a planar body face to revolve instead of a sketch profile.
#[serde(default)]
pub face: Option<Point3>,
/// The body whose face to use, where faces of several bodies meet at `face`.
#[serde(default)]
pub face_body: Option<String>,
/// With `face`, among placed instances: the body whose placement `face` is read in (read by
/// the engine's frame mapping, before the command runs). Default: `face_body`, else the
/// body nearest the point.
#[serde(default)]
pub body: Option<String>,
/// How the revolve combines with existing bodies. Default: new.
#[serde(default)]
pub operation: Option<OperationParam>,
/// The bodies a join, cut or intersect works on. Default for a join from a body's face, or
/// from a sketch on one: that body. Explicit `targets` or `participants` replace it.
#[serde(default)]
pub targets: Option<StringList>,
/// What a join makes of pieces that share no face. Default: separate.
#[serde(default)]
pub touching: Option<TouchingParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Something a feature's result tells the user without stopping them, with an action to take.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FeatureNotice {
+167 -1
View File
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use super::{
BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams, ScaleParams, SphereParams, TorusParams,
};
use super::{SurfaceSweepParams, SweepParams};
use crate::Session;
@@ -52,6 +52,31 @@ fn schemas_and_the_key_check_agree() {
"touching",
],
);
assert_keys(
input_schema_for::<RevolveParams>(),
&[
"angle",
"angle2",
"axis",
"body",
"body_name",
"body_names",
"convention",
"direction",
"exact_regions",
"face",
"face_body",
"name",
"operation",
"participants",
"profiles",
"sketch",
"symmetric",
"targets",
"to",
"touching",
],
);
assert_keys(
input_schema_for::<CylinderParams>(),
&["axis", "base", "body_name", "body_names", "diameter", "height", "name", "operation", "participants", "radius", "references", "targets"],
@@ -165,6 +190,41 @@ fn strict_keeps_todays_errors() {
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
("solid.extrude", json!({"distance": " "})),
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
("solid.revolve", json!({"axis": "y", "operation": "bogus"})),
("solid.revolve", json!({"axis": "y", "operation": "auto"})),
("solid.revolve", json!({"axis": "y", "angle": "no_such_parameter"})),
("solid.revolve", json!({"axis": "y", "angle2": "no_such_parameter"})),
("solid.revolve", json!({"axis": "y", "to": [1, 2]})),
("solid.revolve", json!({"axis": "y", "to": null})),
("solid.revolve", json!({"axis": "y", "convention": "per_side"})),
("solid.revolve", json!({"axis": "y", "convention": "SKETCH_NORMAL"})),
("solid.revolve", json!({"axis": "y", "convention": null})),
("solid.revolve", json!({"axis": "y", "sketch": true})),
("solid.revolve", json!({"axis": "y", "sketch": null})),
("solid.revolve", json!({"axis": "y", "sketch": "Missing"})),
("solid.revolve", json!({"axis": "y", "profiles": []})),
("solid.revolve", json!({"axis": "y", "profiles": [{"point": [1]}]})),
("solid.revolve", json!({"axis": "y", "profiles": [true]})),
("solid.revolve", json!({"axis": "y", "participants": ["Missing"]})),
("solid.revolve", json!({"axis": null})),
("solid.revolve", json!({"axis": 5})),
("solid.revolve", json!({"axis": [0, 1, 0]})),
("solid.revolve", json!({"axis": true})),
("solid.revolve", json!({"axis": {}})),
("solid.revolve", json!({"axis": {"origin": [0, 0, 0]}})),
("solid.revolve", json!({"axis": {"dir": [0, 1]}})),
("solid.revolve", json!({"axis": {"dir": "y"}})),
("solid.revolve", json!({"axis": {"origin": [0, 0], "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": "o", "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": null, "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": ["no_such_parameter", 0, 0], "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"type": "world"}})),
("solid.revolve", json!({"axis": {"type": "world", "axis": "y"}})),
("solid.revolve", json!({"axis": {"type": "world", "axis": 5}})),
("solid.revolve", json!({"axis": {"edge": "x"}})),
("solid.revolve", json!({"axis": {"face": [1, 2]}})),
("solid.revolve", json!({"axis": {"type": "edge"}})),
("solid.revolve", json!({"axis": {"edge": [1, 2, 3]}})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": "W"})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": " w "})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": [0, 0, 0]})),
@@ -866,3 +926,109 @@ fn converted_primitives_and_body_commands_reply_with_feature_result() {
let (changed, removed) = (r["changed_bodies"].as_array().cloned(), r["removed_bodies"].as_array().cloned());
assert!(changed.is_some_and(|c| c.contains(&json!("Bounding"))) && removed.is_some_and(|r| r.contains(&json!("Peg"))), "{r}");
}
/// A 2 x 10 profile standing off the Y axis in the XY plane, a box to join to or turn a face of,
/// and a construction axis to turn about.
fn revolve_rig(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("sketch.create", json!({"name": "Profile", "plane": "XY"})),
("sketch.rectangle.two_point", json!({"p0": [2, 0], "p1": [4, 10]})),
("sketch.finish", json!({})),
("solid.box", json!({"corner": [20, 0, 0], "length": 5, "width": 5, "height": 5, "body_name": "Block"})),
("construct.axis.two_points", json!({"a": {"point": [0, 0, 0]}, "b": {"point": [0, 1, 0]}, "name": "Spindle"})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
s
}
/// Every form a revolve call takes.
fn revolve_calls() -> Vec<Value> {
vec![
json!({"sketch": "Profile", "axis": "y", "angle": "90 deg", "operation": "new", "name": "A", "body_name": "A"}),
json!({"axis": "Y", "angle": 45, "direction": "Symmetric", "body_names": ["B"]}),
json!({"sketch": 1, "axis": {"origin": ["0 mm", 0, 0], "dir": {"x": 0, "y": 1, "z": 0}}, "symmetric": true, "convention": "legacy_axis_start"}),
json!({"axis": {"dir": [0, 1, 0]}, "angle": "30 deg", "angle2": "15 deg", "profiles": "all", "exact_regions": true}),
json!({"axis": {"type": "world", "axis": "Y"}, "angle": 20, "profiles": [0], "convention": "sketch_normal"}),
json!({"axis": "Spindle", "angle": 10, "direction": null, "symmetric": null, "touching": null}),
json!({"axis": {"type": "sketch_line", "dir": [0, 1, 0]}, "to": [0, 5, 3], "angle": 5, "angle2": null}),
json!({"axis": "y", "angle": "60 deg", "operation": "union", "targets": ["Block"], "participants": [], "touching": "one_body"}),
json!({"axis": "y", "angle": 15, "operation": "join", "participants": ["A"], "name": "Join"}),
json!({"axis": "y", "angle": 15, "operation": "cut", "targets": "A"}),
json!({"axis": {"origin": [0, 0, 5], "dir": [0, 1, 0]}, "face": [22.5, 2.5, 5], "face_body": "Block", "body": "Block", "angle": 90}),
]
}
/// `solid.revolve` replies with `FeatureResult` in each of its forms, its contract taking each: in
/// Warn a contract that refused one would still run it, so the replies are checked for warnings.
#[test]
fn revolve_replies_match_the_output_schema_and_forms_are_accepted() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let mut s = revolve_rig(ParamCheck::Warn);
for p in revolve_calls() {
let r = s.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
}
// Warn reports what the contract refuses and runs the call anyway.
let typo = s.execute("solid.revolve", &json!({"axis": "y", "angel": 10})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(typo["param_warnings"], json!(["unknown key `angel`"]));
assert!(conforms(schema, schema, &typo), "{typo}");
}
/// The check changes nothing about the reply: each call gives the same JSON with the check off
/// and in strict (`ms`, the time it took, apart), and its keys are the ones the command always
/// answered with.
#[test]
fn revolve_reply_keeps_its_old_shape_whatever_the_check() {
let (mut off, mut strict) = (revolve_rig(ParamCheck::Off), revolve_rig(ParamCheck::Strict));
for p in revolve_calls() {
let run = |s: &mut Session| {
let mut r = s.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let keys: BTreeSet<String> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
assert!(r["ms"].is_null() || r["ms"].is_number(), "{r}");
r["ms"] = Value::Null;
(r, keys)
};
let (a, a_keys) = run(&mut off);
let (b, _) = run(&mut strict);
assert_eq!(a, b, "{p}");
let old = ["bodies", "changed_bodies", "created_bodies", "feature", "ms", "name", "removed_bodies", "warning"];
assert!(old.iter().all(|k| a_keys.contains(*k)), "{a}");
assert!(a_keys.iter().all(|k| old.contains(&k.as_str()) || matches!(k.as_str(), "component" | "notice")), "{a}");
}
}
/// What the handler ignores or reads as left out, strict refuses; off and warn run it as before.
#[test]
fn revolve_wrong_typed_keys_are_reported() {
for (key, value) in [
("direction", json!("negative")),
("direction", json!(true)),
("symmetric", json!("yes")),
("exact_regions", json!(1)),
("angle", json!(true)),
("angle", json!(" ")),
("angle2", json!([])),
("face", json!("x")),
("face", json!([1, 2])),
("face_body", json!(5)),
("body", json!(5)),
("name", json!(5)),
("body_name", json!(5)),
("operation", json!(5)),
("colour", json!("red")),
] {
let p = json!({"axis": "y", "angle": 30, key: value});
let mut off = revolve_rig(ParamCheck::Off);
let r = off.execute("solid.revolve", &p);
assert!(r.is_ok(), "{p}: {r:?}");
let mut warn = revolve_rig(ParamCheck::Warn);
let r = warn.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{p}: {r}");
let mut strict = revolve_rig(ParamCheck::Strict);
assert!(strict.execute("solid.revolve", &p).is_err(), "{p}");
}
}
+2 -2
View File
@@ -235,8 +235,8 @@ pub struct ExportResult {
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` also StlAscii,
/// ThreeMfTagged or Design).
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` and
/// `file.save_mesh` also StlAscii or ThreeMfTagged, and for `file.export` also Design).
pub format: String,
/// OBJ only: every file written, the material library first and the mesh file last.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
+374
View File
@@ -0,0 +1,374 @@
//! Contract and reply type for `file.insert_mesh` in `cmd/file.rs` (#13309).
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler
//! reads it and fails with the handler's message. Whether the file can be read stays the
//! handler's to check. A key the handler refuses when it is left out (`path`) is optional in the
//! type and required in the schema, so the handler's message, not the check's "is required", is
//! what a caller sees.
//!
//! Some keys are read only in some cases, and the contract reads them in the same cases:
//!
//! - nothing but `path` is read when the file is not a mesh file (the handler refuses it first);
//! - `up` is read only for a glTF file (any other file is refused with the handler's own message,
//! whatever `up` says, so that message is left to the handler);
//! - `names` and `folder` are read only when `up` is given for a glTF file: the handler checks
//! them there, as it does for a STEP file, and they change nothing for a mesh;
//! - `units` is read whatever the file (a glTF or 3MF file is refused it afterwards, which the
//! handler says).
//!
//! A value of the wrong type that the handler reads as if the key were left out (a `name` that is
//! not text) is refused by the contract, as schemas.md has it: that difference is what warn and
//! strict report. A call with several problems may get a different one first in strict than with
//! the check off (the check goes key by key, the handler in its own order), except that a bad
//! `path` is always the first.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use super::check::{located, whole};
use super::types::{not_blank_pattern, whitespace_class};
/// What the handler says of a `path` that is missing, not text, blank or too long.
const PATH_REQUIRED: &str = "`path` is required";
const UP_MESSAGE: &str = "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)";
const NAMES_MESSAGE: &str = "`names` must be \"auto\", \"solid\", \"product\" or \"instance\"";
const FOLDER_MESSAGE: &str =
"`folder` must be true (an import that brings more than one item goes into a `Group #N` history folder) or false (no folder)";
const ORIGIN_MESSAGE: &str =
"`origin` must be true (the mesh is moved so its box is centred on X and Y with its base on Z = 0) or false (the file's coordinates are kept)";
/// A key that is absent or `null` (the handler reads both as left out), else the value read by
/// `read`, with the handler's `message` when `read` refuses it.
fn read_key<'de, D: Deserializer<'de>, T>(d: D, message: &str, read: impl FnOnce(&Value) -> Option<T>) -> Result<Option<T>, D::Error> {
let value = Value::deserialize(d)?;
if value.is_null() {
return Ok(None);
}
read(&value).map(Some).ok_or_else(|| D::Error::custom(whole(message)))
}
fn path_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(Some(s)),
_ => Err(D::Error::custom(whole(PATH_REQUIRED))),
}
}
fn up_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<UpAxis>, D::Error> {
read_key(d, UP_MESSAGE, |v| match v.as_str().map(|u| u.trim().to_ascii_lowercase()).as_deref() {
Some("z") => Some(UpAxis::Z),
Some("y") => Some(UpAxis::Y),
_ => None,
})
}
fn names_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<StepNamesParam>, D::Error> {
read_key(d, NAMES_MESSAGE, |v| {
let word = v.as_str().and_then(solidcraft_io::StepNames::parse)?;
Some(match word {
solidcraft_io::StepNames::Auto => StepNamesParam::Auto,
solidcraft_io::StepNames::Solid => StepNamesParam::Solid,
solidcraft_io::StepNames::Product => StepNamesParam::Product,
solidcraft_io::StepNames::Instance => StepNamesParam::Instance,
})
})
}
fn units_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<MeshUnits>, D::Error> {
let value = Value::deserialize(d)?;
match &value {
Value::Null => Ok(None),
Value::String(u) => match solidcraft_doc::point_cloud::UNITS.iter().find(|(name, _)| name.eq_ignore_ascii_case(u)) {
Some((name, _)) => Ok(Some(MeshUnits((*name).to_string()))),
None => Err(D::Error::custom(whole(format!("`units` must be one of {} (not `{u}`)", unit_names())))),
},
_ => Err(D::Error::custom(whole(format!("`units` must be one of {}", unit_names())))),
}
}
fn flag_key<'de, D: Deserializer<'de>>(d: D, message: &str) -> Result<Option<bool>, D::Error> {
read_key(d, message, Value::as_bool)
}
fn folder_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag_key(d, FOLDER_MESSAGE)
}
fn origin_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag_key(d, ORIGIN_MESSAGE)
}
fn crease_angle_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
let value = Value::deserialize(d)?;
crate::cmd::mesh_crease_angle(Some(&value)).map_err(|message| D::Error::custom(whole(message)))
}
/// The unit names, as the handler lists them in its messages.
fn unit_names() -> String {
solidcraft_doc::point_cloud::UNITS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
}
/// The keys `file.insert_mesh` reads. See [`InsertMeshParams`], which leaves out the keys the
/// handler does not read for the call.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = path_required)]
pub struct InsertMeshKeys {
// Optional in the type and required in the schema (`path_required`): left out, the handler
// refuses it with its own message.
/// The file to insert: `.3mf`, `.stl`, `.obj`, `.ply` (with faces), `.glb` or `.gltf` (any
/// case), not blank and fewer than 4096 UTF-8 bytes. Any other kind of file is refused. Its
/// meshes join the design as mesh bodies in a new component, placed in the active component.
#[serde(default, deserialize_with = "path_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub path: Option<String>,
/// The new component's name, and the feature's. Default (also when blank): the file's name
/// without its extension.
#[serde(default)]
pub name: Option<String>,
/// For a glTF file only (any other file is refused it): `y` (the default: glTF has Y up)
/// turns the import's component a quarter turn about X, so Y becomes Z, and the reply gives
/// its occurrence as `stand_up`; `z` keeps the file's axes. Any case.
#[serde(default, deserialize_with = "up_key", skip_serializing_if = "Option::is_none")]
pub up: Option<UpAxis>,
/// Read only when `up` is given for a glTF file, where it is checked and changes nothing:
/// `auto`, `solid`, `product` or `instance`, in any case. It is how a STEP file's bodies are
/// named (`file.insert_step`).
#[serde(default, deserialize_with = "names_key", skip_serializing_if = "Option::is_none")]
pub names: Option<StepNamesParam>,
/// Read only when `up` is given for a glTF file, where it is checked and changes nothing:
/// true or false. It is whether a STEP import goes into a history folder (`file.insert_step`).
#[serde(default, deserialize_with = "folder_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub folder: Option<bool>,
/// For an STL, OBJ or PLY file: the unit its coordinates are in, scaled to millimetres as the
/// file is read: `mm`, `cm`, `m`, `in` or `ft`, in any case. Default: mm. A 3MF or glTF file
/// states its own, and is refused `units`. The design keeps the millimetre mesh and not the
/// unit, so insert the file again to try another.
#[serde(default, deserialize_with = "units_key", skip_serializing_if = "Option::is_none")]
pub units: Option<MeshUnits>,
/// Whether, once the mesh is read, scaled and stood up, the import's occurrence is moved so
/// the box around its bodies is centred on X and Y of the active component with its lowest
/// point on Z = 0 (the reply gives the shift as `moved_to_origin`). Default: false, so the
/// mesh keeps the coordinates its file gives it.
#[serde(default, deserialize_with = "origin_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub origin: Option<bool>,
/// Shading crease angle in degrees, from 0 through 180. Zero clears imported normals for
/// the previous flat-shading behavior; positive angles regenerate angle-weighted normals
/// across manifold edges below this angle. Omitted or null keeps authored OBJ normals and
/// smoothing groups, or flat shading when absent. Geometry stays unchanged.
#[serde(default, deserialize_with = "crease_angle_key", skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0.0, max = 180.0))]
pub crease_angle_degrees: Option<f64>,
}
/// Leaves out the keys the handler does not read for this call, in the order it looks at them.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let Some(path) = keys.get("path").and_then(Value::as_str).filter(|p| !p.trim().is_empty() && p.len() < 4096).map(str::to_string) else {
return;
};
if !solidcraft_io::is_mesh_path(&path) && !solidcraft_io::is_ply_path(&path) {
// Refused for its kind before anything else is read.
for key in ["name", "up", "names", "folder", "units", "origin", "crease_angle_degrees"] {
keys.remove(key);
}
return;
}
let up_given = keys.get("up").is_some_and(|u| !u.is_null());
if !(up_given && solidcraft_io::is_gltf_path(&path)) {
keys.remove("names");
keys.remove("folder");
}
if !solidcraft_io::is_gltf_path(&path) {
keys.remove("up");
}
}
/// Insert a 3MF, STL, OBJ, PLY or glTF mesh file into the design as mesh bodies, in a new
/// component named after the file and placed in the active component. A glTF file's node tree
/// becomes nested components held as one rigid group until `component.unlock`, a mesh placed
/// again becomes another occurrence, its materials become the bodies' appearances, and its
/// metres become millimetres.
#[derive(Debug)]
pub struct InsertMeshParams(pub InsertMeshKeys);
impl<'de> Deserialize<'de> for InsertMeshParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
// A bad `path` is the handler's first refusal, whatever else is wrong.
if let Some(path) = value.as_object().map(|o| o.get("path")) {
let ok = path.and_then(Value::as_str).is_some_and(|p| !p.trim().is_empty() && p.len() < 4096);
if !ok {
let only = serde_json::json!({"path": path.cloned().unwrap_or(Value::Null)});
return serde_path_to_error::deserialize::<_, InsertMeshKeys>(&only).map(InsertMeshParams).map_err(|e| D::Error::custom(located(&e)));
}
}
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map(InsertMeshParams).map_err(|e| D::Error::custom(located(&e)))
}
}
impl JsonSchema for InsertMeshParams {
fn schema_name() -> Cow<'static, str> {
"InsertMeshParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertMeshKeys::json_schema(generator);
schema.insert("description".into(), INSERT_MESH.into());
schema
}
}
const INSERT_MESH: &str = "Insert a 3MF, STL, OBJ, PLY or glTF mesh file into the design as mesh bodies, in a new component named after the file and placed in the active component. A glTF file's node tree becomes nested components held as one rigid group until `component.unlock`, a mesh placed again becomes another occurrence, its materials become the bodies' appearances, and its metres become millimetres.";
/// `path` is required, as the handler has it (not blank, fewer than 4096 bytes; `maxLength`
/// counts characters, so 4095 is as close as the schema gets). `names` and `folder` have their
/// shapes only where the handler reads them: when `up` is given (not null) for a glTF file.
fn path_required(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
props.insert(
"path".into(),
serde_json::json!({
"description": props.get("path").and_then(|p| p.get("description")).cloned().unwrap_or_default(),
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern(),
}),
);
let mut read_only_with_up = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let names = read_only_with_up("names");
let folder = read_only_with_up("folder");
schema.insert("anyOf".into(), serde_json::json!([{"properties": {"up": {"type": "null"}}}, {"properties": {"names": names, "folder": folder}}]));
}
/// Which way is up in a glTF file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpAxis {
/// The file's axes are kept.
Z,
/// The file has Y up: its Y becomes Z.
Y,
}
/// Case-insensitive words (`word`), each letter in both cases, as a pattern; the pattern allows
/// the whitespace Rust's `trim` removes around them when `padded`.
fn words_pattern(words: &[&str], padded: bool) -> String {
let word = |w: &str| w.chars().map(|c| format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase())).collect::<String>();
let alternatives = words.iter().map(|w| word(w)).collect::<Vec<_>>().join("|");
if padded {
let ws = whitespace_class();
format!("^[{ws}]*({alternatives})[{ws}]*$")
} else {
format!("^({alternatives})$")
}
}
impl JsonSchema for UpAxis {
fn schema_name() -> Cow<'static, str> {
"UpAxis".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "`z` (the file's axes are kept) or `y` (the file has Y up: its Y becomes Z), in any case.",
"type": "string",
"pattern": words_pattern(&["z", "y"], true)
})
}
}
/// How a STEP file's bodies are named; read, and checked, for a glTF file with `up` too.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StepNamesParam {
Auto,
Solid,
Product,
Instance,
}
impl JsonSchema for StepNamesParam {
fn schema_name() -> Cow<'static, str> {
"StepNamesParam".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "`auto`, `solid`, `product` or `instance`, in any case.",
"type": "string",
"pattern": words_pattern(&["auto", "solid", "product", "instance"], true)
})
}
}
/// A length unit of an STL, OBJ or PLY file: one of `solidcraft_doc::point_cloud::UNITS`, in the
/// case the table spells it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MeshUnits(pub String);
impl JsonSchema for MeshUnits {
fn schema_name() -> Cow<'static, str> {
"MeshUnits".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let names: Vec<&str> = solidcraft_doc::point_cloud::UNITS.iter().map(|(n, _)| *n).collect();
json_schema!({
"description": "`mm`, `cm`, `m`, `in` or `ft`, in any case.",
"type": "string",
"pattern": words_pattern(&names, false)
})
}
}
/// What `file.insert_mesh` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MeshInserted {
/// The file, as given.
pub path: String,
/// The mesh import feature's id.
pub feature: u64,
/// The feature's name.
pub name: String,
/// The bodies the import made, by name.
pub bodies: Vec<String>,
/// The same names as `bodies`.
pub created_bodies: Vec<String>,
/// What the file reader noticed and put right.
pub warnings: Vec<String>,
/// The new component's id.
pub component: u64,
/// The new component's name.
pub component_name: String,
/// The component's occurrence in the active component; null when it has none.
pub occurrence: Option<u64>,
/// How many components were made below it (a glTF file's nodes). Only when there are some.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subcomponents: Option<usize>,
/// How many rigid joints hold its contents together. Only when `subcomponents` is given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rigid_joints: Option<usize>,
/// With `origin`: the shift, [x, y, z] in mm, that moved the import to the origin; absent
/// when it was not moved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moved_to_origin: Option<[f64; 3]>,
/// The unit the file's coordinates were read in, when `units` was given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub units: Option<String>,
/// For a glTF file stood up from Y up to Z up: the occurrence that was turned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stand_up: Option<u64>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "insert_mesh_tests.rs"]
mod tests;
@@ -0,0 +1,326 @@
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::{InsertMeshParams, MeshInserted, StepNamesParam, UpAxis};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<InsertMeshParams>(), &["crease_angle_degrees", "folder", "name", "names", "origin", "path", "units", "up"]);
}
#[test]
fn strict_keeps_todays_errors() {
let long = "x".repeat(4096);
assert_strict_keeps_todays_errors(&[
("file.insert_mesh", json!({})),
("file.insert_mesh", json!({"path": ""})),
("file.insert_mesh", json!({"path": " "})),
("file.insert_mesh", json!({"path": 5})),
("file.insert_mesh", json!({"path": null})),
("file.insert_mesh", json!({"path": long})),
("file.insert_mesh", json!({"up": "q", "units": "yd"})),
("file.insert_mesh", json!({"crease_angle_degrees": "smooth"})),
// The kind of file is refused before anything else is read.
("file.insert_mesh", json!({"path": "x.step"})),
("file.insert_mesh", json!({"path": "x.step", "up": "q", "units": 5, "origin": 1})),
("file.insert_mesh", json!({"path": "x.step", "name": 5})),
("file.insert_mesh", json!({"path": "x.step", "crease_angle_degrees": "smooth"})),
// `up` is for a glTF file only, whatever it says.
("file.insert_mesh", json!({"path": "x.stl", "up": "y"})),
("file.insert_mesh", json!({"path": "x.stl", "up": "q"})),
("file.insert_mesh", json!({"path": "x.obj", "up": 5})),
("file.insert_mesh", json!({"path": "x.glb", "up": "q"})),
("file.insert_mesh", json!({"path": "x.glb", "up": 5})),
("file.insert_mesh", json!({"path": "x.glb", "up": "y", "names": "bogus"})),
("file.insert_mesh", json!({"path": "x.gltf", "up": "z", "names": 5})),
("file.insert_mesh", json!({"path": "x.gltf", "up": "z", "folder": "yes"})),
("file.insert_mesh", json!({"path": "x.stl", "units": "yd"})),
("file.insert_mesh", json!({"path": "x.stl", "units": 5})),
("file.insert_mesh", json!({"path": "x.stl", "units": true})),
("file.insert_mesh", json!({"path": "x.3mf", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.glb", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.stl", "origin": "yes"})),
("file.insert_mesh", json!({"path": "x.stl", "origin": 1})),
("file.insert_mesh", json!({"path": "x.obj", "crease_angle_degrees": -1})),
("file.insert_mesh", json!({"path": "x.obj", "crease_angle_degrees": 181})),
("file.insert_mesh", json!({"path": "x.obj", "crease_angle_degrees": "40"})),
("file.insert_mesh", json!({"path": "x.obj", "crease_angle_degrees": true})),
// The kind of file is read in any case, and a PLY file is a mesh too.
("file.insert_mesh", json!({"path": "X.GLB", "up": "q"})),
("file.insert_mesh", json!({"path": "X.STL", "up": "y"})),
("file.insert_mesh", json!({"path": "X.3MF", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.ply", "units": "yd"})),
("file.insert_mesh", json!({"path": "x.ply", "up": "y"})),
// A file that isn't there.
("file.insert_mesh", json!({"path": "does-not-exist.stl"})),
("file.insert_mesh", json!({"path": "does-not-exist.glb", "up": "Z", "names": " Auto ", "folder": false})),
]);
}
fn parse(p: Value) -> Result<InsertMeshParams, String> {
serde_json::from_value(p).map_err(|e| e.to_string())
}
/// The keys read as the handler reads them: the same words in any case, `null` as left out, and
/// the keys it doesn't read in a call left unread, so a bad one there is no problem.
#[test]
fn the_types_read_each_key_as_the_handler_does() {
let keys = |p: Value| parse(p).unwrap_or_else(|e| panic!("{e}")).0;
let k = keys(json!({"path": "a.glb", "name": "Mine", "up": " Y ", "names": " Solid ", "folder": true, "units": "IN", "origin": false}));
assert_eq!(k.path.as_deref(), Some("a.glb"));
assert_eq!(k.name.as_deref(), Some("Mine"));
assert_eq!(k.up, Some(UpAxis::Y));
assert_eq!(k.names, Some(StepNamesParam::Solid));
assert_eq!(k.folder, Some(true));
assert_eq!(k.units.map(|u| u.0), Some("in".to_string()));
assert_eq!(k.origin, Some(false));
for angle in [0.0, -0.0, 40.5, 180.0] {
assert_eq!(keys(json!({"path": "a.obj", "crease_angle_degrees": angle})).crease_angle_degrees, Some(angle));
}
assert!(keys(json!({"path": "a.obj"})).crease_angle_degrees.is_none());
assert!(keys(json!({"path": "a.obj", "crease_angle_degrees": null})).crease_angle_degrees.is_none());
assert_eq!(keys(json!({"path": "a.glb", "up": "Z"})).up, Some(UpAxis::Z));
let none = keys(json!({"path": "a.stl", "name": null, "up": null, "names": null, "folder": null, "units": null, "origin": null}));
assert!(none.name.is_none() && none.up.is_none() && none.units.is_none() && none.origin.is_none());
// Not read: `names` and `folder` without an `up` for a glTF file, `up` for any other file,
// everything but `path` for a file that isn't a mesh.
for p in [
json!({"path": "a.stl", "names": 5, "folder": "x"}),
json!({"path": "a.glb", "names": 5, "folder": "x"}),
json!({"path": "a.glb", "up": null, "names": 5, "folder": "x"}),
json!({"path": "a.stl", "up": "sideways", "names": 5}),
json!({"path": "a.step", "up": 1, "names": 5, "folder": 5, "units": 5, "origin": 5, "crease_angle_degrees": "smooth"}),
] {
parse(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
}
parse(json!({"path": "a.GLB", "up": "y", "names": "auto"})).unwrap_or_else(|e| panic!("{e}"));
assert!(parse(json!({"path": "a.GLB", "up": "y", "names": "x"})).is_err());
parse(json!({"path": "a.PLY", "up": 5, "names": 5})).unwrap_or_else(|e| panic!("{e}"));
let k = keys(json!({"path": "a.glb", "names": 5}));
assert!(k.names.is_none() && k.folder.is_none());
// Read, and refused with the handler's message.
let refused = |p: Value| crate::schema::issues("file.insert_mesh", &p).into_iter().next().map(|i| i.message).unwrap_or_default();
for (p, message) in [
(json!({"path": "a.glb", "up": "x"}), "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)"),
(json!({"path": "a.glb", "up": "y", "names": "x"}), "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""),
(json!({"path": "a.stl", "units": "yd"}), "`units` must be one of mm, cm, m, in, ft (not `yd`)"),
(json!({"path": "a.stl", "units": 1}), "`units` must be one of mm, cm, m, in, ft"),
(json!({"path": "a.3mf", "units": []}), "`units` must be one of mm, cm, m, in, ft"),
(json!({"path": "a.stl", "origin": "yes"}), "`origin` must be true"),
(json!({"path": "a.glb", "up": "z", "folder": 0}), "`folder` must be true"),
(json!({"path": "a.obj", "crease_angle_degrees": -1}), "crease_angle_degrees must be between 0 and 180 degrees"),
(json!({"path": "a.obj", "crease_angle_degrees": 181}), "crease_angle_degrees must be between 0 and 180 degrees"),
(json!({"path": "a.obj", "crease_angle_degrees": "40"}), "crease_angle_degrees must be between 0 and 180 degrees"),
(json!({}), "`path` is required"),
(json!({"path": " "}), "`path` is required"),
(json!({"up": "x", "path": 5}), "`path` is required"),
] {
let e = refused(p.clone());
assert!(e.starts_with(message), "{p}: {e}");
}
}
/// The check names what is wrong, in the handler's words, and a key the handler would ignore is
/// still a known key.
#[test]
fn the_check_names_what_is_wrong() {
let issues = |p: Value| crate::schema::issues("file.insert_mesh", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert_eq!(issues(json!({"path": "a.stl", "unit": "in"})), ["unknown key `unit`; did you mean `units`?"]);
assert_eq!(issues(json!({"path": "a.stl", "units": "yd"})), ["`units` must be one of mm, cm, m, in, ft (not `yd`)"]);
assert_eq!(issues(json!({"path": "a.stl", "name": 5})), ["`name` must be text"]);
assert_eq!(
issues(json!({"path": "a.obj", "crease_angle_degree": 40})),
["unknown key `crease_angle_degree`; did you mean `crease_angle_degrees`?"]
);
assert_eq!(issues(json!({})), ["`path` is required"]);
// `name` is read as text or not at all; a number is ignored by the handler but is a mistake.
assert_eq!(issues(json!({"path": "a.stl", "name": null})), Vec::<String>::new());
assert!(issues(json!({"path": "a.stl", "up": "y", "names": 5})).is_empty(), "an `up` for another file is the handler's to refuse");
assert!(issues(json!({"path": "a.stl", "names": 5, "folder": 5})).is_empty());
}
/// The published schema: `path` is required and shaped as the handler reads it; `names` and
/// `folder` have their shapes only where they are read.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<InsertMeshParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.glb", "up": "Y", "names": "Auto", "folder": true, "name": "N"}), true),
(json!({"path": "a.stl", "units": "in", "origin": true}), true),
(json!({"path": "a.stl", "units": null, "origin": null, "up": null}), true),
(json!({"path": "a.obj", "crease_angle_degrees": null}), true),
(json!({"path": "a.obj", "crease_angle_degrees": 0}), true),
(json!({"path": "a.obj", "crease_angle_degrees": 40.5}), true),
(json!({"path": "a.obj", "crease_angle_degrees": 180}), true),
// Left unread, so not shaped.
(json!({"path": "a.stl", "names": 5, "folder": "x"}), true),
(json!({"path": "a.glb", "up": null, "names": 5, "folder": "x"}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a.stl", "origin": "yes"}), false),
(json!({"path": "a.stl", "units": 5}), false),
(json!({"path": "a.stl", "folder": "x", "up": "y"}), false),
(json!({"path": "a.glb", "up": "y", "names": 5}), false),
(json!({"path": "a.stl", "name": 5}), false),
(json!({"path": "a.obj", "crease_angle_degrees": -1}), false),
(json!({"path": "a.obj", "crease_angle_degrees": 181}), false),
(json!({"path": "a.obj", "crease_angle_degrees": "40"}), false),
(json!({"path": "a.obj", "crease_angle_degrees": true}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// `conforms` doesn't read patterns: pin the text of the ones the schema carries.
#[test]
fn the_patterns_are_pinned() {
let schema = input_schema_for::<InsertMeshParams>();
let schema = schema.as_value();
assert_eq!(schema["properties"]["path"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(schema["properties"]["path"]["maxLength"], json!(4095));
let ws = crate::schema::types::whitespace_class();
assert_eq!(schema["$defs"]["UpAxis"]["pattern"], json!(format!("^[{ws}]*([zZ]|[yY])[{ws}]*$")));
assert_eq!(schema["$defs"]["MeshUnits"]["pattern"], json!("^([mM][mM]|[cC][mM]|[mM]|[iI][nN]|[fF][tT])$"));
assert_eq!(
schema["$defs"]["StepNamesParam"]["pattern"],
json!(format!("^[{ws}]*([aA][uU][tT][oO]|[sS][oO][lL][iI][dD]|[pP][rR][oO][dD][uU][cC][tT]|[iI][nN][sS][tT][aA][nN][cC][eE])[{ws}]*$"))
);
}
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-insert-mesh-schema-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
/// A cube of side 10 mm, saved as STL, and the same as 3MF.
fn cube_files(dir: &Path) -> (String, String) {
let mut part = Session::default();
let (stl, threemf) = (dir.join("cube.stl").to_string_lossy().to_string(), dir.join("cube.3mf").to_string_lossy().to_string());
for (id, p) in [
("solid.box", json!({"length": 10, "width": 10, "height": 10})),
("file.save_mesh", json!({"path": stl})),
("file.export_3mf", json!({"path": threemf})),
] {
part.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
(stl, threemf)
}
/// A glTF file (a glb): a cube of side 0.01 m placed by a `Base` node with an `Arm` node below
/// it, so the import has components of its own.
fn glb(dir: &Path) -> String {
let s = 0.01f32;
let p: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32 * s, ((i >> 1) & 1) as f32 * s, ((i >> 2) & 1) as f32 * s]).collect();
let t: [u16; 36] = [0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7, 0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5];
let mut bin: Vec<u8> = p.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
bin.extend(t.iter().flat_map(|v| v.to_le_bytes()));
let json = json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [
{"name": "Base", "mesh": 0, "children": [1]},
{"name": "Arm", "mesh": 1, "translation": [0.0, 0.05, 0.0]},
],
"meshes": [
{"name": "Block", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
{"name": "Peg", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [s, s, s]},
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"},
],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
"buffers": [{"byteLength": bin.len()}],
});
let path = dir.join("robot.glb");
std::fs::write(&path, solidcraft_io::glb_container(json.to_string().as_bytes(), &bin)).unwrap_or_else(|e| panic!("{e}"));
path.to_string_lossy().to_string()
}
fn matches(reply: &Value) {
let schema = output_schema_for::<MeshInserted>();
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
}
/// Every form the command takes, run in Warn: each reply matches its output schema and carries
/// no warning (Warn runs a call the contract refuses anyway, so this shows the contract takes
/// each call). The keys the handler leaves unread are given bad values on purpose.
#[test]
fn replies_match_the_output_schema() {
let dir = folder("replies");
let (stl, threemf) = cube_files(&dir);
let robot = glb(&dir);
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |p: Value| {
let r = s.execute("file.insert_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
r
};
let plain = run(json!({"path": stl}));
assert_eq!(plain["component_name"], "cube", "{plain}");
assert!(plain.get("units").is_none() && plain.get("moved_to_origin").is_none() && plain.get("stand_up").is_none(), "{plain}");
assert!(plain.get("subcomponents").is_none() && plain["bodies"] == plain["created_bodies"], "{plain}");
assert!(plain["occurrence"].is_u64(), "{plain}");
let named = run(json!({"path": stl, "name": "Block", "units": "IN", "origin": true}));
assert_eq!(named["units"], "in", "{named}");
assert_eq!(named["component_name"], "Block", "{named}");
assert!(named["moved_to_origin"].as_array().is_some_and(|a| a.len() == 3), "{named}");
run(json!({"path": stl, "units": "cm", "origin": false, "up": null, "name": null}));
for angle in [Value::Null, json!(0), json!(40.5), json!(180)] {
run(json!({"path": stl, "crease_angle_degrees": angle}));
}
// Keys left unread, with values that would be refused if they were read.
run(json!({"path": stl, "names": 5, "folder": "x"}));
run(json!({"path": threemf, "names": [], "folder": {}}));
run(json!({"path": robot, "names": 5, "folder": "x"}));
let stood = run(json!({"path": robot}));
assert!(stood["stand_up"].is_u64() && stood["subcomponents"].is_u64() && stood["rigid_joints"].is_u64(), "{stood}");
let kept = run(json!({"path": robot, "up": "Z", "names": " Instance ", "folder": true, "origin": true}));
assert!(kept.get("stand_up").is_none() && kept["subcomponents"].is_u64(), "{kept}");
let turned = run(json!({"path": robot, "up": " y "}));
assert!(turned["stand_up"].is_u64(), "{turned}");
// A misspelt key still runs in Warn, says so, and the reply still matches.
let warned = s.execute("file.insert_mesh", &json!({"path": stl, "unit": "in"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `unit`; did you mean `units`?"]), "{warned}");
matches(&warned);
let _ = std::fs::remove_dir_all(&dir);
}
/// In Strict a call that works today is not refused, and one that fails fails with the
/// handler's message (see `strict_keeps_todays_errors`).
#[test]
fn strict_runs_the_calls_that_work() {
let dir = folder("strict");
let (stl, _) = cube_files(&dir);
let robot = glb(&dir);
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
for p in [
json!({"path": stl, "units": "mm"}),
json!({"path": stl, "crease_angle_degrees": null}),
json!({"path": stl, "crease_angle_degrees": 0}),
json!({"path": stl, "crease_angle_degrees": 40.5}),
json!({"path": stl, "crease_angle_degrees": 180}),
json!({"path": robot, "up": "y", "names": "auto", "folder": false}),
] {
s.execute("file.insert_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
}
let e = s.execute("file.insert_mesh", &json!({"path": stl, "unit": "in"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("unknown key `unit`"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
+5
View File
@@ -54,6 +54,7 @@ mod coverage_tests;
mod tests;
// The contracts, by command file.
pub mod align;
pub mod appearance;
pub mod bom;
pub mod browser;
@@ -71,6 +72,7 @@ pub mod features;
pub mod features_more;
pub mod file;
pub mod frame;
pub mod insert_mesh;
pub mod inspect;
pub mod interference;
pub mod joint_sweep;
@@ -84,6 +86,7 @@ pub mod metadata;
pub mod motion;
pub mod motion_analysis;
pub mod motion_play;
pub mod new_design;
pub mod panels;
pub mod parameter_sweep;
pub mod parameters;
@@ -105,6 +108,7 @@ pub mod render_stage;
pub mod render_surface_resources;
pub mod render_ui;
pub mod save;
pub mod save_mesh;
pub mod section;
pub mod select;
pub mod sheet;
@@ -124,6 +128,7 @@ pub mod sketch_modify;
pub mod sketch_project;
pub mod sketch_session;
pub mod sketch_shapes;
pub mod slicers;
pub mod split;
pub mod surface;
pub mod surface_analysis;
+34
View File
@@ -0,0 +1,34 @@
//! Contracts and result types for the commands in `cmd/file.rs` that start a design: `file.new`
//! (#13305).
//!
//! The handler reads its parameters from the raw JSON, so the key here reads as the handler reads
//! it. `file.new` refuses nothing: a `name` that is not text is read as left out, so the
//! handler has no message of its own for the check to keep.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Start a new, empty design and make it the current one, replacing the design that is open
/// (save it first: this does not ask). A name you give is kept by Save As. Not an undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NewDesignParams {
/// The design's name. Default: "Untitled". A name other than "Untitled" (and not blank) is
/// the user's own, so Save As offers it as the file name.
#[serde(default)]
pub name: Option<String>,
}
/// What `file.new` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NewDesignResult {
/// The new design's name: the `name` given, else "Untitled".
pub name: String,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "new_design_tests.rs"]
mod tests;
@@ -0,0 +1,92 @@
use serde_json::{Value, json};
use super::{NewDesignParams, NewDesignResult};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<NewDesignParams>(), &["name"]);
let issues = |p: Value| crate::schema::issues("file.new", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert!(issues(json!({})).is_empty());
assert!(issues(json!({"name": "Gearbox"})).is_empty());
assert_eq!(issues(json!({"nmae": "Gearbox"})).len(), 1);
}
/// `file.new` has no call the handler refuses, so strict has no error of today's to keep: every
/// call that works with the check off still works in strict, with the same reply. A `name` that
/// is not text is the one call strict now refuses, which the handler read as left out.
#[test]
fn strict_keeps_todays_replies() {
let reply = |level: ParamCheck, p: &Value| {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("file.new", p).map_err(|e| e.to_string())
};
for p in [
json!({}),
json!(null),
json!({"name": null}),
json!({"name": "Gearbox lid"}),
json!({"name": ""}),
json!({"name": " "}),
json!({"name": "Untitled"}),
] {
let off = reply(ParamCheck::Off, &p);
assert!(off.is_ok(), "{p}");
assert_eq!(reply(ParamCheck::Strict, &p), off, "{p}");
}
assert_eq!(reply(ParamCheck::Off, &json!({"name": 5})), Ok(json!({"name": "Untitled"})));
assert!(reply(ParamCheck::Strict, &json!({"name": 5})).is_err());
}
/// The type reads `name` as the handler does: text, or `null` and absent as left out.
#[test]
fn the_types_read_each_key_as_the_handler_does() {
let read = |p: Value| serde_json::from_value::<NewDesignParams>(p).map(|p| p.name);
assert_eq!(read(json!({})).ok(), Some(None));
assert_eq!(read(json!({"name": null})).ok(), Some(None));
assert_eq!(read(json!({"name": "Lid"})).ok(), Some(Some("Lid".to_string())));
assert_eq!(read(json!({"name": ""})).ok(), Some(Some(String::new())));
assert!(read(json!({"name": 5})).is_err());
}
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<NewDesignParams>();
let schema = schema.as_value();
for (p, takes) in [(json!({}), true), (json!({"name": "Lid"}), true), (json!({"name": 5}), false), (json!({"title": "x"}), false)] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// Real calls in Warn reply as before, with no warnings, and match the output schema.
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<NewDesignResult>();
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
for (p, name) in [
(json!({}), "Untitled"),
(json!({"name": null}), "Untitled"),
(json!({"name": "Gearbox lid"}), "Gearbox lid"),
(json!({"name": ""}), ""),
(json!({"name": "Untitled"}), "Untitled"),
] {
let reply = s.execute("file.new", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert_eq!(reply, json!({"name": name}), "{p}");
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{p}: {reply}");
}
assert_eq!(s.doc.name, "Untitled");
}
/// A misspelt key still runs in Warn, says so, and the reply still matches.
#[test]
fn a_misspelt_key_is_reported_in_warn() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let reply = s.execute("file.new", &json!({"nmae": "Lid"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(reply["name"], "Untitled", "{reply}");
assert!(reply["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{reply}");
let schema = output_schema_for::<NewDesignResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
}
+1 -1
View File
@@ -27,7 +27,7 @@ pub struct PartsLibraryParams {
#[schemars(deny_unknown_fields)]
pub struct PartsInsertParams {
/// The family, as `parts.library` lists it: socket_head_cap_screw, button_head_cap_screw,
/// hex_bolt, hex_nut, washer, set_screw, dowel_pin, bearing, hex_cap_screw_inch,
/// countersunk_socket_screw, hex_bolt, hex_nut, washer, set_screw, dowel_pin, bearing, hex_cap_screw_inch,
/// hex_nut_inch, washer_sae or washer_uss.
#[serde(deserialize_with = "family")]
pub family: String,
+14 -7
View File
@@ -186,9 +186,10 @@ impl JsonSchema for PresentationParam {
}
}
/// The file format Export Render Image writes: `png`, the finished image as shown, or `exr`,
/// the raw linear FLOAT32 master, which the job must have kept (`linear_exr`). The handler
/// reads it as this type.
/// The file format Export Render Image writes: `png`, the finished image as shown; `exr`,
/// the raw linear FLOAT32 master, which the job must have kept (`linear_exr`); or
/// `denoise_mask`, the categorical PNG guide mask kept with `denoise_diagnostics`.
/// The handler reads it as this type.
// The variants have no doc comments on purpose: with them, schemars publishes `oneOf` and
// `const` instead of a plain `enum`, which the schema checks in the tests don't read.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -197,13 +198,14 @@ pub enum ExportFormat {
#[default]
Png,
Exr,
DenoiseMask,
}
impl ExportFormat {
/// The file extension the path must end with.
pub fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Png | Self::DenoiseMask => "png",
Self::Exr => "exr",
}
}
@@ -397,17 +399,22 @@ pub struct ImageExported {
/// How many bytes were written.
pub bytes: usize,
pub format: ExportFormat,
/// The file's colour encoding: `srgb` for a PNG, `scene_linear_srgb_d65` for an EXR.
/// The file's colour encoding: `srgb` for a colour PNG, `scene_linear_srgb_d65` for an
/// EXR, or `categorical_srgb` for a guide mask.
pub encoding: &'static str,
/// The file's alpha: `straight` for a PNG, `coverage_premultiplied` for an EXR.
/// The file's alpha: `straight` for a colour PNG, `coverage_premultiplied` for an EXR,
/// or `opaque` for a guide mask.
pub alpha: &'static str,
/// The finished image.
/// The parent finished colour image, including its colour PNG hash when a guide mask
/// is exported. The separate mask metadata describes the exported mask bytes.
pub frame: Frame,
/// The linear master the job kept, as the render crate describes it, or `null`.
pub linear_master: Option<Map<String, Value>>,
/// How a regraded image was presented, as the render crate describes it, or `null` for an
/// image rendered directly.
pub presentation: Option<Map<String, Value>>,
/// The guide mask the job kept, including its PNG byte count, dimensions and SHA, or `null`.
pub denoise_mask: Option<Map<String, Value>>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
+30 -6
View File
@@ -47,6 +47,7 @@ fn schemas_and_the_key_check_agree() {
}
assert_keys(input_schema_for::<RegradeParams>(), &["job", "presentation"]);
assert_keys(input_schema_for::<ExportParams>(), &["format", "job", "overwrite", "path"]);
assert_eq!(input_schema_for::<super::ExportFormat>().as_value()["enum"], json!(["png", "exr", "denoise_mask"]));
}
/// #12836: `JobPhase` names every phase the render crate has, as it spells them. The match has
@@ -103,6 +104,7 @@ fn strict_keeps_todays_errors() {
("render.export", json!({"path": "shot.png", "format": null})),
("render.export", json!({"path": "shot.png", "overwrite": "yes"})),
("render.export", json!({"path": "shot.txt"})),
("render.export", json!({"path": "mask.exr", "format": "denoise_mask"})),
("render.export", json!({"path": "shot.png", "job": foreign_job()})),
]);
}
@@ -168,6 +170,7 @@ fn the_checks_take_what_the_schemas_allow() {
("render.export", json!({"path": "shot.png"}), true),
("render.export", json!({"path": "shot.txt"}), true),
("render.export", json!({"path": "shot.exr", "format": "exr", "overwrite": true, "job": null}), true),
("render.export", json!({"path": "mask.png", "format": "denoise_mask", "job": job}), true),
("render.export", json!({"path": 5}), false),
("render.export", json!({"path": "shot.png", "format": "PNG"}), false),
("render.export", json!({"path": "shot.png", "format": null}), false),
@@ -215,7 +218,7 @@ fn render_job_commands_reply_with_their_schemas() {
assert_eq!(
exported,
json!({"job": job, "path": path, "bytes": written, "format": "png", "encoding": "srgb", "alpha": "straight",
"frame": state["frame"], "linear_master": null, "presentation": null})
"frame": state["frame"], "linear_master": null, "presentation": null, "denoise_mask": null})
);
assert_eq!(reply(&mut s, "render.result.pin", json!({"job": null}), &pinned), json!({"frame": null}));
@@ -230,8 +233,8 @@ fn render_job_commands_reply_with_their_schemas() {
}
/// #12836: Regrade Linear Master replies as its output schema says, and so does Export Render
/// Image for the linear master (EXR) and for the regraded image, whose replies describe the
/// master and the presentation.
/// Image for the linear master (EXR), the regraded colour PNG and the retained guide masks.
/// Every export preserves the parent colour frame, master, presentation and mask metadata.
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn regrade_and_export_of_a_linear_master_reply_with_their_schemas() {
@@ -239,7 +242,8 @@ fn regrade_and_export_of_a_linear_master_reply_with_their_schemas() {
s.execute(
"render.settings.set",
&json!({"name": "Master", "set": {"width": 8, "height": 8, "job": {"method": "cpu_raytrace",
"options": {"progressive": {"samples_per_pixel": 1, "seed": 7}, "lights": [], "output": {"linear_exr": true}}}}}),
"options": {"progressive": {"samples_per_pixel": 1, "seed": 7}, "lights": [], "denoise_diagnostics": true,
"output": {"linear_exr": true}}}}}),
)
.unwrap_or_else(|e| panic!("{e}"));
s.set_render_job_host(Some(CpuHost::for_platform().unwrap_or_else(|e| panic!("{e}")))).unwrap_or_else(|e| panic!("{e}"));
@@ -257,10 +261,30 @@ fn regrade_and_export_of_a_linear_master_reply_with_their_schemas() {
let regraded: JobId = serde_json::from_value(regraded["job"].clone()).unwrap_or_else(|e| panic!("{e}"));
wait(&s, regraded);
let exported = output_schema_for::<ImageExported>();
for (job, format) in [(master, "exr"), (regraded, "png")] {
let path = std::env::temp_dir().join(format!("solidcraft-render-jobs-schema-{}-{}.{format}", std::process::id(), job.serial));
for (job, format, extension) in
[(master, "exr", "exr"), (regraded, "png", "png"), (master, "denoise_mask", "png"), (regraded, "denoise_mask", "png")]
{
let frame = s.render_jobs.frame(job).unwrap_or_else(|e| panic!("{e}"));
let mask_info = json!(frame.denoise_mask_info().unwrap_or_else(|| panic!("missing retained guide mask")));
let expected = match format {
"exr" => frame.linear_exr().unwrap_or_else(|| panic!("missing retained linear master")),
"denoise_mask" => frame.denoise_mask_png().unwrap_or_else(|| panic!("missing retained guide mask")),
_ => frame.png(),
};
let path = std::env::temp_dir().join(format!("solidcraft-render-jobs-schema-{}-{}-{format}.{extension}", std::process::id(), job.serial));
let r = reply(&mut s, "render.export", json!({"job": job, "path": path, "format": format, "overwrite": true}), &exported);
assert_eq!(std::fs::read(&path).unwrap_or_else(|e| panic!("{e}")), expected);
let _ = std::fs::remove_file(&path);
assert_eq!(r["format"], format);
assert_eq!(r["frame"], json!(frame.info));
assert_eq!(r["denoise_mask"], mask_info);
let (encoding, alpha) = match format {
"exr" => ("scene_linear_srgb_d65", "coverage_premultiplied"),
"denoise_mask" => ("categorical_srgb", "opaque"),
_ => ("srgb", "straight"),
};
assert_eq!(r["encoding"], encoding);
assert_eq!(r["alpha"], alpha);
assert!(r["linear_master"].is_object(), "{r}");
if job == regraded {
assert!(r["presentation"].is_object(), "{r}");
+364
View File
@@ -0,0 +1,364 @@
//! Contracts and result types for `file.save_mesh` in `cmd/file.rs`.
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler reads
//! it and fails with the handler's message. Whether a body exists, a slicer is installed or the file
//! can be written stays the handler's to check. A call with several problems may get a different
//! one first in strict than with the check off (the check goes key by key, the handler in its own
//! order).
//!
//! `path` is the one key the handler refuses when it is left out: it is optional in the type, so the
//! handler keeps its message, and required in the schema.
//!
//! Two keys are read only in some cases, and the contract reads them in the same cases: `ascii`
//! only when `format` is `stl` (in any case), and `single_instance` only when `slicer` is text. A
//! value of the wrong type that the handler reads as if the key were left out (`ascii: "yes"` next
//! to `format: "stl"`, a number for `slicer`) is refused by the contract, as schemas.md has it: that
//! difference is what warn and strict report.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use super::check::{located, whole};
use super::types::{not_blank_pattern, whitespace_class};
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
T::deserialize(d).map(Some)
}
/// The keys `file.save_mesh` reads. See [`SaveMeshParams`], which leaves `ascii` and
/// `single_instance` unread in the cases the handler does.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = conditional_keys)]
pub struct SaveMeshKeys {
/// The file to write: not blank, fewer than 4096 UTF-8 bytes.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "MeshPath")]
pub path: Option<MeshPath>,
/// The file format, in any case: `stl`, `3mf`, or `3mf-tagged` (3MF with a face tag on each
/// triangle, docs/formats/3mf-face-tags.md). Default: from the extension of `path` (`.3mf`
/// gives 3mf), else stl.
#[serde(default)]
pub format: Option<MeshFormat>,
/// The bodies to save, by name. Default: every body.
#[serde(default)]
pub bodies: Option<MeshBodies>,
/// STL only (read only when `format` is `stl`): true writes ASCII STL instead of binary.
/// Default: false.
#[serde(default)]
pub ascii: Option<bool>,
/// A slicer to send the saved file to, after it is written where `path` says, through the
/// same steps as `file.send_to_slicer`: an id or a name from `file.slicers`, the program path
/// it lists, or a program the user added under Custom…. A slicer that has to be started first
/// is waited for. Kept for scripts; may be deprecated later in favour of `file.send_to_slicer`
/// or a Send to App workflow. Default: none.
#[serde(default)]
pub slicer: Option<String>,
/// Read only when `slicer` is given: true opens the file in the window the slicer already has
/// open, for slicers that load files that way (see `file.send_to_slicer`). Default: true.
#[serde(default)]
pub single_instance: Option<bool>,
/// How closely the mesh follows curved faces (STL, OBJ and 3MF), in any case: `standard`, `fine`
/// or `finest`, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size. Default: standard.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// 3MF only: `body` is one object per body; `component` is one object for each placed
/// component with two or more bodies, with those bodies as its parts, so a slicer loads them as
/// one object with parts (other bodies stay objects of their own). `component` is refused for
/// other formats. Default: body.
#[serde(default)]
pub objects: Option<MeshObjects>,
}
/// Leaves out the keys the handler never reads for this call: `ascii` unless `format` is `stl`,
/// and `single_instance` unless `slicer` is text.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let stl = keys.get("format").and_then(Value::as_str).is_some_and(|f| f.eq_ignore_ascii_case("stl"));
if !stl {
keys.remove("ascii");
}
if !keys.get("slicer").is_some_and(Value::is_string) {
keys.remove("single_instance");
}
}
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<SaveMeshKeys, D::Error> {
let mut value = Value::deserialize(d)?;
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
}
/// Save bodies as a mesh file: binary or ASCII STL, 3MF, or 3MF with a face tag on each triangle.
/// The format is `format`, else the extension of `path`, else STL. With `slicer`, the saved file
/// is then sent to that program. Nothing is added to undo.
#[derive(Debug)]
pub struct SaveMeshParams(pub SaveMeshKeys);
impl<'de> Deserialize<'de> for SaveMeshParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(SaveMeshParams)
}
}
impl JsonSchema for SaveMeshParams {
fn schema_name() -> Cow<'static, str> {
"SaveMeshParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = SaveMeshKeys::json_schema(generator);
schema.insert("description".into(), SAVE_MESH.into());
schema
}
}
const SAVE_MESH: &str = "Save bodies as a mesh file: binary or ASCII STL, 3MF, or 3MF with a face tag on each triangle. The format is `format`, else the extension of `path`, else STL. With `slicer`, the saved file is then sent to that program. Nothing is added to undo.";
/// The pattern for `format`: the three words, in any case.
const FORMAT_PATTERN: &str = "^([Ss][Tt][Ll]|3[Mm][Ff]|3[Mm][Ff]-[Tt][Aa][Gg][Gg][Ee][Dd])$";
/// The pattern for `quality`: the three words, in any case, with whitespace around them allowed
/// (the handler trims).
fn quality_pattern() -> String {
let ws = whitespace_class();
format!("^[{ws}]*([Ss][Tt][Aa][Nn][Dd][Aa][Rr][Dd]|[Ff][Ii][Nn][Ee]|[Ff][Ii][Nn][Ee][Ss][Tt])[{ws}]*$")
}
/// `path` is required (the handler says so when it is left out); `ascii` and `single_instance`
/// have their shapes only where the handler reads them, as [`leave_unread`] has it.
fn conditional_keys(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
let mut read_only_when = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let ascii = read_only_when("ascii");
let single_instance = read_only_when("single_instance");
// `format` is `stl` in any case: the eight spellings, which `enum` says and a pattern would only approximate.
let spellings: Vec<String> =
(0..8).map(|n| "stl".chars().enumerate().map(|(i, c)| if n >> i & 1 == 1 { c.to_ascii_uppercase() } else { c }).collect()).collect();
let stl = json!({"required": ["format"], "properties": {"format": {"enum": spellings}}});
let slicer = json!({"required": ["slicer"], "properties": {"slicer": {"type": "string"}}});
schema.insert(
"allOf".into(),
json!([
{"anyOf": [{"not": stl}, {"properties": {"ascii": ascii}}]},
{"anyOf": [{"not": slicer}, {"properties": {"single_instance": single_instance}}]},
]),
);
}
/// The file to write, as `path_arg` reads `path`: not blank and under 4096 bytes, or the
/// handler's "`path` is required" (anything but text reads as no `path`).
#[derive(Clone, Debug, PartialEq)]
pub struct MeshPath(pub String);
impl<'de> Deserialize<'de> for MeshPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(MeshPath(s)),
_ => Err(D::Error::custom(whole("`path` is required"))),
}
}
}
impl JsonSchema for MeshPath {
fn schema_name() -> Cow<'static, str> {
"MeshPath".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
// `maxLength` counts characters, so 4095 is as close as the schema gets to "under 4096 bytes".
json_schema!({
"description": "The file to write: not blank, fewer than 4096 UTF-8 bytes.",
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern()
})
}
}
/// The bodies to save, read by the handler's own `string_list`: a name or a list of names, with its
/// messages for anything else.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshBodies(pub Vec<String>);
impl<'de> Deserialize<'de> for MeshBodies {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let keys = json!({"bodies": Value::deserialize(d)?});
match crate::params::string_list("file.save_mesh", &keys, "bodies") {
Ok(names) => Ok(MeshBodies(names)),
Err(crate::EngineError::BadParams { msg, .. }) => Err(D::Error::custom(whole(msg))),
Err(e) => Err(D::Error::custom(whole(e))),
}
}
}
impl JsonSchema for MeshBodies {
fn schema_name() -> Cow<'static, str> {
"MeshBodies".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A body name, or a list of body names (at most 10000).",
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": crate::params::MAX_STRING_LIST}]
})
}
}
/// A mesh format, as `file.save_mesh` reads `format` (the words in any case).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshFormat {
/// STL, binary unless `ascii` is true.
Stl,
/// 3MF.
ThreeMf,
/// 3MF with a face tag on each triangle.
ThreeMfTagged,
}
impl<'de> Deserialize<'de> for MeshFormat {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match String::deserialize(d)?.to_ascii_lowercase().as_str() {
"stl" => Ok(MeshFormat::Stl),
"3mf" => Ok(MeshFormat::ThreeMf),
"3mf-tagged" => Ok(MeshFormat::ThreeMfTagged),
other => Err(D::Error::custom(whole(format!("unknown mesh format `{other}` (stl, 3mf, 3mf-tagged)")))),
}
}
}
impl JsonSchema for MeshFormat {
fn schema_name() -> Cow<'static, str> {
"MeshFormat".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "stl, 3mf or 3mf-tagged, in any case.",
"type": "string",
"pattern": FORMAT_PATTERN
})
}
}
/// How closely the mesh follows curved faces, as `quality` reads it (the words in any case,
/// whitespace around them ignored).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshQualityParam {
/// A chord tolerance of 2e-4 of each body's size.
Standard,
/// 5e-5 of each body's size.
Fine,
/// 1e-5 of each body's size.
Finest,
}
impl<'de> Deserialize<'de> for MeshQualityParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let named = v.as_str().filter(|s| solidcraft_io::MeshQuality::from_name(s).is_some()).map(|s| s.trim().to_ascii_lowercase());
match named.as_deref() {
Some("standard") => Ok(MeshQualityParam::Standard),
Some("fine") => Ok(MeshQualityParam::Fine),
Some("finest") => Ok(MeshQualityParam::Finest),
_ => Err(D::Error::custom(whole(format!("`quality` must be standard, fine or finest, not {v}")))),
}
}
}
impl JsonSchema for MeshQualityParam {
fn schema_name() -> Cow<'static, str> {
"MeshQualityParam".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "standard (a chord tolerance of 2e-4 of each body's size), fine (5e-5) or finest (1e-5), in any case.",
"type": "string",
"pattern": quality_pattern()
})
}
}
/// What each 3MF object holds, as `objects` reads it (lower case only).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshObjects {
/// One object per body.
Body,
/// One object for each placed component with two or more bodies.
Component,
}
impl<'de> Deserialize<'de> for MeshObjects {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match v.as_str() {
Some("body") => Ok(MeshObjects::Body),
Some("component") => Ok(MeshObjects::Component),
_ => Err(D::Error::custom(whole(format!("`objects` must be body or component, not {v}")))),
}
}
}
impl JsonSchema for MeshObjects {
fn schema_name() -> Cow<'static, str> {
"MeshObjects".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "body (one object per body) or component (one object for each placed component with two or more bodies, with those bodies as its parts). Lower case only.",
"type": "string",
"enum": ["body", "component"]
})
}
}
/// What happened when the saved file was handed to `slicer`. Only the one that applies is there.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct SlicerDelivery {
/// The slicer's name: the file was opened there.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer: Option<String>,
/// Why the program would not open the file. The file is saved either way.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer_error: Option<String>,
/// The slicer's name: the slicer had to be started first and was not ready (never for
/// `file.save_mesh`, which waits for it).
#[serde(skip_serializing_if = "Option::is_none")]
pub starting: Option<String>,
/// The slicer's name: it was started but was not ready within 10 seconds, so the file was
/// not sent. The file is saved.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer_timeout: Option<String>,
}
/// What `file.save_mesh` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SaveMeshResult {
/// The file written, as given.
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: StlBinary, StlAscii, ThreeMf or ThreeMfTagged.
pub format: String,
/// With `slicer` only: the bodies that were laid on their print face for the slicer.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub print_faces: Vec<String>,
/// With `slicer` only: a warning for each body that has a print face but was saved as it lies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// With `slicer` only: what happened when the file was handed over.
#[serde(flatten)]
pub delivery: SlicerDelivery,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "save_mesh_tests.rs"]
mod tests;
+303
View File
@@ -0,0 +1,303 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::{SaveMeshParams, SaveMeshResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-13308-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn with_box(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})).unwrap();
s.execute("solid.cylinder", &json!({"radius": 3, "height": 8, "body_name": "Pin", "operation": "new"})).unwrap();
s
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SaveMeshParams>(), &["ascii", "bodies", "format", "objects", "path", "quality", "single_instance", "slicer"]);
// `path` is the key the handler refuses when it is left out: required in the schema, optional in the type.
let schema = input_schema_for::<SaveMeshParams>();
assert_eq!(schema.as_value()["required"], json!(["path"]));
assert!(serde_json::from_value::<SaveMeshParams>(json!({})).is_ok());
// The patterns are pinned (`conforms` doesn't read them).
let defs = &schema.as_value()["$defs"];
assert_eq!(defs["MeshFormat"]["pattern"], "^([Ss][Tt][Ll]|3[Mm][Ff]|3[Mm][Ff]-[Tt][Aa][Gg][Gg][Ee][Dd])$");
assert_eq!(defs["MeshPath"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(defs["MeshPath"]["maxLength"], 4095);
let quality = defs["MeshQualityParam"]["pattern"].as_str().unwrap();
assert!(quality.starts_with("^[\\u0009-\\u000d\\u0020") && quality.contains("[Ff][Ii][Nn][Ee][Ss][Tt]"), "{quality}");
assert_eq!(schema.as_value()["allOf"].as_array().map(Vec::len), Some(2));
}
/// Strict gives the handler's message for the calls it refuses for a key. Nothing is written: each
/// of these is refused before the file is.
#[test]
fn strict_keeps_todays_errors() {
let p = |extra: Value| {
let mut p = json!({"path": "/nonexistent-13308/out.stl"});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let mut cases: Vec<(&str, Value)> = vec![
("file.save_mesh", json!({})),
("file.save_mesh", json!({"path": ""})),
("file.save_mesh", json!({"path": " "})),
("file.save_mesh", json!({"path": 5})),
("file.save_mesh", json!({"path": null})),
("file.save_mesh", json!({"path": ["a.stl"]})),
("file.save_mesh", json!({"path": "x".repeat(4096)})),
];
for extra in [
json!({"format": "obj"}),
json!({"format": "step"}),
json!({"format": ""}),
json!({"format": "3MF-Tagged "}),
json!({"quality": "ultra"}),
json!({"quality": 5}),
json!({"quality": true}),
json!({"quality": ""}),
json!({"objects": "Body"}),
json!({"objects": "parts"}),
json!({"objects": 5}),
json!({"objects": false}),
json!({"bodies": 5}),
json!({"bodies": [1, "a"]}),
json!({"bodies": {"a": 1}}),
json!({"bodies": true}),
json!({"format": "stl", "bodies": ["a", null]}),
] {
cases.push(("file.save_mesh", p(extra)));
}
assert_strict_keeps_todays_errors(&cases);
}
/// The refusals only the handler can make (they need the design or two keys together) are the same
/// in strict, and nothing is written.
#[test]
fn strict_keeps_the_handlers_own_refusals() {
let dir = temp_dir("own");
let at = |n: &str| dir.join(n).to_string_lossy().into_owned();
for p in [
json!({"path": at("a.stl"), "objects": "component"}),
json!({"path": at("b.stl"), "bodies": ["Nope"]}),
json!({"path": at("c.3mf"), "slicer": "no such slicer anywhere"}),
json!({"path": at("d.stl"), "slicer": ""}),
] {
let error = |level: ParamCheck| with_box(level).execute("file.save_mesh", &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// The types read each key as the handler does: keys it never reads in a call are not read by the
/// contract either, and the ones it reads fail with its words.
#[test]
fn the_types_read_like_the_handler() {
let reads = |p: Value| serde_json::from_value::<SaveMeshParams>(p).is_ok();
let issues = |p: Value| crate::schema::issues("file.save_mesh", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
// `ascii` is read only for `format: stl`, in any case; `single_instance` only with a text `slicer`.
assert!(reads(json!({"path": "a.stl", "ascii": "yes"})) && reads(json!({"path": "a.stl", "ascii": 1, "format": "3mf"})));
assert!(reads(json!({"path": "a.stl", "ascii": {}, "format": null})) && reads(json!({"path": "a.stl", "ascii": [], "format": null})));
assert!(reads(json!({"path": "a.stl", "single_instance": "no"})) && reads(json!({"path": "a.stl", "single_instance": 0, "slicer": null})));
assert!(reads(json!({"path": "a.stl", "format": "STL", "ascii": true})) && reads(json!({"path": "a.stl", "format": "Stl", "ascii": null})));
assert!(reads(json!({"path": "a.stl", "slicer": "prusa", "single_instance": false})));
assert!(!reads(json!({"path": "a.stl", "format": "stl", "ascii": "yes"})) && !reads(json!({"path": "a.stl", "format": "STL", "ascii": 1})));
assert!(!reads(json!({"path": "a.stl", "slicer": "prusa", "single_instance": "no"})));
// `null` reads as left out where the handler does.
assert!(reads(json!({"path": "a.stl", "format": null, "bodies": null, "slicer": null, "quality": null, "objects": null})));
// Stricter than the handler, which reads a wrong type as left out.
assert!(!reads(json!({"path": "a.stl", "format": 5})) && !reads(json!({"path": "a.stl", "slicer": 5})));
assert!(!reads(json!({"path": "a.stl", "slicer": true})));
// The words the handler takes: any case for the format and the quality (trimmed), lower case for `objects`.
for format in ["stl", "3MF", "3mf-tagged", "3Mf-TaGGed"] {
assert!(reads(json!({"path": "a", "format": format})), "{format}");
}
for quality in ["standard", "FINE", " Finest\n"] {
assert!(reads(json!({"path": "a", "quality": quality})), "{quality:?}");
}
assert!(reads(json!({"path": "a", "objects": "body"})) && reads(json!({"path": "a", "objects": "component"})));
assert!(reads(json!({"path": "a", "bodies": "A"})) && reads(json!({"path": "a", "bodies": ["A", "B"]})));
assert!(reads(json!({"path": "a", "bodies": []})));
// The handler's own messages.
assert_eq!(issues(json!({"path": ""})), ["`path` is required"]);
assert_eq!(issues(json!({"path": 5})), ["`path` is required"]);
assert_eq!(issues(json!({"path": null})), ["`path` is required"]);
assert_eq!(issues(json!({"path": "a", "format": "OBJ"})), ["unknown mesh format `obj` (stl, 3mf, 3mf-tagged)"]);
assert_eq!(issues(json!({"path": "a", "quality": "ultra"})), ["`quality` must be standard, fine or finest, not \"ultra\""]);
assert_eq!(issues(json!({"path": "a", "quality": 5})), ["`quality` must be standard, fine or finest, not 5"]);
assert_eq!(issues(json!({"path": "a", "objects": "Body"})), ["`objects` must be body or component, not \"Body\""]);
assert_eq!(issues(json!({"path": "a", "objects": null})), Vec::<String>::new());
assert_eq!(issues(json!({"path": "a", "bodies": 5})), ["`bodies` must be a list of names; it is 5"]);
assert_eq!(issues(json!({"path": "a", "pth": 1})), ["unknown key `pth`; did you mean `path`?"]);
// A key the handler doesn't read in this call is not checked, so no warning for it.
assert!(issues(json!({"path": "a", "format": "3mf", "ascii": "yes", "single_instance": 3})).is_empty());
assert_eq!(issues(json!({"path": "a", "format": "stl", "ascii": "yes"})), ["`ascii` must be true or false"]);
}
/// What the schema takes: the same calls as the type, including the keys read only in some cases.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<SaveMeshParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.3mf", "format": "3MF", "bodies": ["A"], "quality": "fine", "objects": "component"}), true),
(json!({"path": "a", "format": "stl", "ascii": true}), true),
(json!({"path": "a", "slicer": "prusa", "single_instance": false}), true),
(json!({"path": "a", "bodies": "A"}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a", "format": 5}), false),
(json!({"path": "a", "objects": "x"}), false),
(json!({"path": "a", "bodies": 5}), false),
(json!({"path": "a", "wibble": 1}), false),
// Read only in some cases: anything elsewhere, the shape where it is read.
(json!({"path": "a", "ascii": "yes"}), true),
(json!({"path": "a", "format": "3mf", "ascii": "yes"}), true),
(json!({"path": "a", "format": "stl", "ascii": "yes"}), false),
(json!({"path": "a", "single_instance": "no"}), true),
(json!({"path": "a", "slicer": 5, "single_instance": "no"}), false),
(json!({"path": "a", "slicer": "x", "single_instance": "no"}), false),
] {
// `conforms` doesn't read `pattern`: the cases a pattern alone refuses are in the type tests.
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
fn matches(r: &Value) {
let schema = output_schema_for::<SaveMeshResult>();
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
}
fn keys(r: &Value) -> BTreeSet<String> {
r.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default()
}
/// Every form of the command replies as its output schema says, in warn and without warnings, with
/// the keys it always had; a misspelt key gets its warning and the reply still matches.
#[test]
fn replies_match_the_output_schema() {
let dir = temp_dir("replies");
let at = |n: &str| dir.join(n).to_string_lossy().into_owned();
let mut s = with_box(ParamCheck::Warn);
let run = |s: &mut Session, p: Value| {
let r = s.execute("file.save_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
assert_eq!(keys(&r), ["bytes", "format", "path"].map(String::from).into(), "{p}: {r}");
assert_eq!(r["bytes"].as_u64(), std::fs::metadata(r["path"].as_str().unwrap()).ok().map(|m| m.len()), "{p}: {r}");
r
};
for (p, format) in [
(json!({"path": at("a.stl")}), "StlBinary"),
(json!({"path": at("b.3mf")}), "ThreeMf"),
(json!({"path": at("c.dat")}), "StlBinary"),
(json!({"path": at("d.dat"), "format": "STL"}), "StlBinary"),
(json!({"path": at("e.stl"), "format": "stl", "ascii": true}), "StlAscii"),
(json!({"path": at("f.stl"), "format": "Stl", "ascii": null}), "StlBinary"),
(json!({"path": at("g.stl"), "format": "3mf"}), "ThreeMf"),
(json!({"path": at("h.3mf"), "format": "3MF-tagged"}), "ThreeMfTagged"),
(json!({"path": at("i.stl"), "bodies": "Block", "quality": "Fine"}), "StlBinary"),
(json!({"path": at("j.3mf"), "bodies": ["Block", "Pin"], "quality": " finest ", "objects": "component"}), "ThreeMf"),
(json!({"path": at("k.3mf"), "objects": "body", "bodies": []}), "ThreeMf"),
(json!({"path": at("l.stl"), "format": null, "bodies": null, "quality": null, "objects": null, "slicer": null}), "StlBinary"),
// Keys the handler doesn't read in these calls take anything, and get no warning.
(json!({"path": at("m.3mf"), "ascii": "yes"}), "ThreeMf"),
(json!({"path": at("n.stl"), "single_instance": [1], "ascii": {}}), "StlBinary"),
(json!({"path": at("o.stl"), "format": "3mf", "ascii": 7, "single_instance": "no"}), "ThreeMf"),
] {
let r = run(&mut s, p.clone());
assert_eq!(r["format"], format, "{p}");
}
assert!(std::fs::read_to_string(at("e.stl")).unwrap().starts_with("solid"));
assert!(std::fs::read(at("a.stl")).unwrap().len() > 84);
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
let p = json!({"path": at("p.stl"), "qualty": "fine"});
let r = s.execute("file.save_mesh", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`qualty`")))), "{r}");
matches(&r);
assert_eq!(r["format"], "StlBinary");
s.param_check = ParamCheck::Strict;
let error = s.execute("file.save_mesh", &json!({"path": at("q.stl"), "qualty": "fine"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`qualty`") && error.contains("did you mean `quality`"), "{error}");
assert!(!std::path::Path::new(&at("q.stl")).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// With a `slicer`: the reply says where the file went; a body with a print face is laid on it and
/// listed. The program is a script that records what it is given.
#[cfg(unix)]
#[test]
fn replies_with_a_slicer_match_the_output_schema() {
use std::os::unix::fs::PermissionsExt;
let _turn = crate::tests::mesh_convert::CHOSEN_PROGRAMS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = temp_dir("slicer");
let (script, seen) = (dir.join("fake-slicer"), dir.join("seen"));
std::fs::write(&script, format!("#!/bin/sh\necho \"$@\" >> {}\n", seen.display())).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let spec = script.to_string_lossy().into_owned();
crate::slicers::set_chosen_programs(std::slice::from_ref(&spec));
let mut s = with_box(ParamCheck::Warn);
s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 5]})).unwrap();
let out = dir.join("part.stl").to_string_lossy().into_owned();
let r = s.execute("file.save_mesh", &json!({"path": out, "slicer": spec, "single_instance": false}));
let again =
s.execute("file.save_mesh", &json!({"path": dir.join("x.3mf"), "slicer": spec, "format": "3mf", "objects": "component", "bodies": "Block"}));
crate::slicers::set_chosen_programs(&[]);
let (r, again) = (r.unwrap(), again.unwrap());
for r in [&r, &again] {
assert!(r.get("param_warnings").is_none(), "{r}");
matches(r);
assert!(r["slicer"].is_string() && r.get("slicer_error").is_none(), "{r}");
}
assert_eq!(keys(&r), ["bytes", "format", "path", "print_faces", "slicer"].map(String::from).into(), "{r}");
assert_eq!(r["print_faces"], json!(["Block"]));
assert_eq!(r["path"], json!(out));
assert_eq!(keys(&again), ["bytes", "format", "path", "print_faces", "slicer"].map(String::from).into(), "{again}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The typed reply serializes to the keys the handler's JSON had, and nothing for what is unset.
#[test]
fn the_reply_type_serializes_as_before() {
use super::{SaveMeshResult, SlicerDelivery};
let reply = |delivery| SaveMeshResult {
path: "a.stl".into(),
bytes: 5,
format: "StlBinary".into(),
print_faces: Vec::new(),
warnings: Vec::new(),
delivery,
param_warnings: Vec::new(),
};
assert_eq!(serde_json::to_value(reply(SlicerDelivery::default())).unwrap(), json!({"path": "a.stl", "bytes": 5, "format": "StlBinary"}));
// Print faces and warnings are there when there are any, as `Laid::report` put them.
let laid = SaveMeshResult { print_faces: vec!["A".into()], warnings: vec!["w".into()], ..reply(SlicerDelivery::default()) };
let r = serde_json::to_value(laid).unwrap();
assert_eq!(r, json!({"path": "a.stl", "bytes": 5, "format": "StlBinary", "print_faces": ["A"], "warnings": ["w"]}));
matches(&r);
for (delivery, key) in [
(SlicerDelivery { slicer: Some("P".into()), ..Default::default() }, "slicer"),
(SlicerDelivery { slicer_error: Some("e".into()), ..Default::default() }, "slicer_error"),
(SlicerDelivery { starting: Some("P".into()), ..Default::default() }, "starting"),
(SlicerDelivery { slicer_timeout: Some("P".into()), ..Default::default() }, "slicer_timeout"),
] {
let r = serde_json::to_value(reply(delivery)).unwrap();
assert_eq!(keys(&r), ["bytes", "format", "path", key].map(String::from).into(), "{r}");
matches(&r);
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Contracts and result types for the command in `cmd/file.rs` that lists the installed slicers:
//! `file.slicers` (#13306).
//!
//! The handler reads no parameter, so its contract has no key: a key in a call is reported in
//! warn and refused in strict, and a call without one runs as it always did.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// `file.slicers` takes no parameters.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SlicersParams {}
/// What `file.slicers` returns: the slicers found on this machine, known ones in a fixed order
/// (empty in the browser). Pass an entry's `id`, its `name` or its `program` as
/// `file.send_to_slicer`'s or `file.save_mesh`'s `slicer`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SlicersResult {
/// The slicers installed on this machine.
pub slicers: Vec<SlicerInfo>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// One installed slicer.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SlicerInfo {
/// `prusa`, `bambu`, … for the known ones, `custom` for a program the user picked. When one
/// slicer is installed more than once, each install has its own id (`prusa#<where it was
/// found>`).
pub id: String,
/// The name to show, which says which install it is when there are several.
pub name: String,
/// The program that is started, as a path or a command name; null when there is none.
pub program: Option<String>,
/// The path of the application's icon file (a PNG, a macOS `.icns` file or a Windows
/// program); null when none was found.
pub icon: Option<String>,
}
#[cfg(test)]
#[path = "slicers_tests.rs"]
mod tests;
+108
View File
@@ -0,0 +1,108 @@
use serde_json::{Value, json};
use super::{SlicerInfo, SlicersParams, SlicersResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SlicersParams>(), &[]);
}
/// The handler reads no key, so there is no value of one to refuse: strict refuses a key it does
/// not know, with the check's own message, and the handler never refuses anything.
#[test]
fn the_type_reads_no_key_as_the_handler_reads_none() {
assert!(serde_json::from_value::<SlicersParams>(json!({})).is_ok());
// An unknown key is the key check's to report, not the type's.
assert!(serde_json::from_value::<SlicersParams>(json!({"path": "x"})).is_ok());
let mut s = Session { param_check: ParamCheck::Off, ..Session::default() };
for p in [json!({}), json!({"anything": 1, "path": null})] {
assert!(s.execute("file.slicers", &p).is_ok(), "{p}");
}
let schema = input_schema_for::<SlicersParams>();
assert!(conforms(schema.as_value(), schema.as_value(), &json!({})));
assert!(!conforms(schema.as_value(), schema.as_value(), &json!({"slicer": "prusa"})));
}
/// Strict has nothing of today's to keep: no call of `file.slicers` is refused by the handler.
/// Called with no key it runs, as it always did, and a misspelt one is the check's to refuse.
#[test]
fn strict_keeps_todays_behaviour() {
assert_strict_keeps_todays_errors(&[]);
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
assert!(s.execute("file.slicers", &json!({})).is_ok());
assert!(s.execute("file.slicers", &json!({"slicer": "prusa"})).is_err());
}
/// A call with no key has nothing to warn about; one with a key is told it is unknown.
#[test]
fn the_check_names_an_unknown_key() {
assert!(issues("file.slicers", &json!({})).is_empty());
assert_eq!(issues("file.slicers", &json!({"slicer": "prusa"})).len(), 1);
}
fn matches(reply: &Value) {
let schema = output_schema_for::<SlicersResult>();
assert!(conforms(schema.as_value(), schema.as_value(), reply), "{reply} doesn't match {schema:?}");
assert!(reply.get("param_warnings").is_none(), "{reply}");
}
/// What the entries serialize to: every key is there, and a missing program or icon is null, as
/// the handler has always answered.
#[test]
fn an_entry_serializes_with_null_for_what_is_missing() {
let full = SlicerInfo { id: "prusa".into(), name: "PrusaSlicer".into(), program: Some("/usr/bin/prusa".into()), icon: Some("/i.png".into()) };
assert_eq!(
serde_json::to_value(full).unwrap_or_default(),
json!({"id": "prusa", "name": "PrusaSlicer", "program": "/usr/bin/prusa", "icon": "/i.png"})
);
let bare = SlicerInfo { id: "x".into(), name: "X".into(), program: None, icon: None };
assert_eq!(serde_json::to_value(bare).unwrap_or_default(), json!({"id": "x", "name": "X", "program": null, "icon": null}));
let none = SlicersResult { slicers: Vec::new(), param_warnings: Vec::new() };
assert_eq!(serde_json::to_value(none).unwrap_or_default(), json!({"slicers": []}));
}
/// The real command in Warn, on this machine's own list, then on made-up ones (one with an icon
/// and a program, one with neither): each reply matches the output schema and carries no
/// warning. A misspelt key still runs, says so, and the reply still matches.
#[cfg(unix)]
#[test]
fn replies_match_the_output_schema() {
use crate::slicers::{IconSource, Slicer, TEST_DETECTED};
use crate::tests::mesh_convert::{CHOSEN_PROGRAMS, MadeUp};
let _turn = CHOSEN_PROGRAMS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = MadeUp;
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let own = s.execute("file.slicers", &json!({})).unwrap_or_else(|e| panic!("{e}"));
assert!(own["slicers"].is_array(), "{own}");
matches(&own);
let entry = |id: &str, command: Vec<String>, icon: Option<IconSource>| Slicer {
id: id.into(),
name: format!("{id} name"),
command,
icon,
prusa_major: None,
};
*TEST_DETECTED.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(vec![
entry("prusa", vec!["/opt/prusa".into(), "--arg".into()], Some(IconSource::Image("/icons/prusa.png".into()))),
entry("bare", Vec::new(), None),
]);
let listed = s.execute("file.slicers", &json!({})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
listed,
json!({"slicers": [
{"id": "prusa", "name": "prusa name", "program": "/opt/prusa", "icon": "/icons/prusa.png"},
{"id": "bare", "name": "bare name", "program": null, "icon": null},
]})
);
matches(&listed);
let warned = s.execute("file.slicers", &json!({"slicr": true})).unwrap_or_else(|e| panic!("{e}"));
assert!(warned["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{warned}");
assert_eq!(warned["slicers"], listed["slicers"]);
let schema = output_schema_for::<SlicersResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
}
+75
View File
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
use super::check::schema_keys;
use super::features::{BoxParams, ExtrudeParams};
use super::types::{RevolveAxis, RevolveConventionParam, RevolveDirection, RevolveToPoint};
use super::*;
use crate::Session;
@@ -1136,6 +1137,80 @@ fn axis_lines_match_the_axis_reader() {
}
}
/// A revolve's `axis` is accepted as `features::axis_param` reads it before it looks anything up,
/// and its schema says the same, apart from what a schema can't say (below).
#[test]
fn revolve_axes_match_the_axis_reader() {
let schema = input_schema_for::<RevolveAxis>();
let schema = schema.as_value();
for (v, accepted) in [
(json!("y"), true),
(json!("Z"), true),
(json!("z"), true),
(json!("l1"), true),
(json!("Spindle"), true),
(json!(""), true),
(json!({"dir": [0, 0, 1]}), true),
(json!({"dir": [0, 0, 0]}), true),
(json!({"origin": [1, 2, 3], "dir": {"x": 0, "y": 1, "z": 0}}), true),
(json!({"origin": ["1 mm", 0, {"x": 1}], "dir": [0, 0, 1]}), false),
(json!({"origin": ["1 mm", "w / 2", 0], "dir": [0, 0, 1]}), true),
(json!({"origin": {"x": 1, "y": 2, "z": "3 mm"}, "dir": [0, 0, 1]}), true),
(json!({"origin": "o", "dir": [0, 0, 1]}), false),
(json!({"origin": null, "dir": [0, 0, 1]}), false),
(json!({"origin": [1, 2], "dir": [0, 0, 1]}), false),
(json!({"origin": [0, 0, 0]}), false),
(json!({"dir": [0, 1]}), false),
(json!({"dir": "y"}), false),
(json!({"dir": [0, 0, 1], "type": "sketch_line", "extra": 1}), true),
(json!({"type": "world", "axis": "X"}), true),
(json!({"type": "world", "axis": "X", "origin": 5, "dir": "q"}), true),
(json!({"type": "world", "axis": "x"}), false),
(json!({"type": "world", "axis": 5}), false),
(json!({"type": "world"}), false),
(json!({"type": "world", "dir": [0, 0, 1]}), false),
(json!({"edge": [1, 2, 3]}), true),
(json!({"face": [1, 2, 3], "dir": [0, 0, 1]}), true),
(json!({"face": "x", "dir": [0, 0, 1]}), false),
(json!({"type": "face", "at": [1, 2, 3]}), true),
(json!({"type": "vertex", "at": [1, 2, 3]}), false),
(json!({"face": "x", "vertex": [1, 2, 3]}), false),
(json!({}), false),
(json!(5), false),
(json!(true), false),
(json!(null), false),
(json!([0, 0, 1]), false),
] {
assert_eq!(parse::<RevolveAxis>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(schema, schema, &v), accepted, "schema {v}");
}
// Where the schema can't follow the reader: a saved edge is read whole by serde.
let v = json!({"type": "edge", "at": "x"});
assert!(parse::<RevolveAxis>(v.clone()).is_none() && conforms(schema, schema, &v), "{v}");
}
/// Revolve's `direction` and `convention` take what the handler reads, and its `to` is a point.
#[test]
fn revolve_keywords_match_the_handler() {
for (v, accepted) in
[(json!("symmetric"), true), (json!("SYMMETRIC"), true), (json!(" symmetric"), false), (json!("negative"), false), (json!(true), false)]
{
assert_eq!(parse::<RevolveDirection>(v.clone()).is_some(), accepted, "{v}");
let schema = input_schema_for::<RevolveDirection>();
assert_eq!(conforms(schema.as_value(), schema.as_value(), &v), accepted && v == json!("symmetric"), "schema {v}");
}
for (v, accepted) in [
(json!("sketch_normal"), true),
(json!("legacy_axis_start"), true),
(json!("Sketch_Normal"), false),
(json!("legacy_per_side"), false),
(json!(null), false),
] {
assert_eq!(parse::<RevolveConventionParam>(v.clone()).is_some(), accepted, "{v}");
}
assert!(parse::<RevolveToPoint>(json!([1, 2, 3])).is_some() && parse::<RevolveToPoint>(json!([1, 2])).is_none());
}
/// A construction plane's name is refused when `features::check_plane_name` refuses it. (The
/// schema can't say which names: see `PlaneName`.)
#[test]
+144 -6
View File
@@ -210,6 +210,12 @@ target_point!(
"must be a point [x, y, z] on the face or point to start from"
);
target_point!(
/// Revolve's `to`: a [`Point3`], with the handler's message.
RevolveToPoint,
"must be a point [x, y, z] to turn to"
);
/// Extrude's `faces`: 1 to 1024 points on coplanar faces of one body, read as the handler reads
/// them, with its messages.
#[derive(Clone, Debug, PartialEq)]
@@ -1360,6 +1366,17 @@ impl JsonSchema for PlaneOrFace {
}
}
/// What `construct::model_axis_param` finds wrong with an edge or face axis before it looks
/// anything up in the design: a reference that isn't a picked or saved face or edge.
fn model_axis_problem(v: &Value) -> Option<String> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
geo_problem(v).or_else(|| match crate::cmd::geo_form(v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
}
/// The line `construct.plane.at_angle` turns about, as `features::axis_line` reads it: an edge or
/// face of the model (as `construct::model_axis_param` reads it), else X, Y, Z, a construction
/// axis's name or `{origin?, dir}`. Kept as given, for the handler. Whether a picked edge or face
@@ -1369,14 +1386,9 @@ pub struct AxisLine(pub Value);
impl<'de> Deserialize<'de> for AxisLine {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
geo_problem(&v).or_else(|| match crate::cmd::geo_form(&v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
model_axis_problem(&v)
} else {
match &v {
Value::String(_) => None,
@@ -1416,6 +1428,132 @@ impl JsonSchema for AxisLine {
}
}
/// What `features::axis_param` says to an `axis` that is none of its forms.
const REVOLVE_AXIS_MESSAGE: &str =
"`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}";
/// What `features::axis_param` finds wrong with an object axis that isn't an edge or face of the
/// model, before it looks anything up in the design.
fn revolve_axis_object_problem(o: &Value) -> Option<String> {
if o.get("type").and_then(Value::as_str) == Some("world") {
let ok = o.get("axis").and_then(Value::as_str).is_some_and(|a| matches!(a, "X" | "Y" | "Z"));
return (!ok).then(|| whole("world axis requires `axis` to be X, Y or Z"));
}
if let Some(origin) = o.get("origin")
&& serde_json::from_value::<PointExpr3>(origin.clone()).is_err()
{
return Some(whole("`origin` must be a point [x, y, z] of numbers or length expressions"));
}
match o.get("dir") {
None => Some(whole("axis needs `dir`")),
Some(dir) => params::vec3(dir).is_none().then(|| whole("axis `dir` must be a direction [x, y, z] of numbers")),
}
}
/// Revolve's `axis`, as `features::axis_param` reads it before it looks anything up: a sketch
/// line's id or name, `x` or `y`, X, Y or Z, a construction axis's name (any text), `{type: "world",
/// axis}`, `{origin?, dir}`, or an edge or face of the model (as `construct::model_axis_param`
/// reads it). Kept as given, for the handler. Whether a line or axis exists, and whether a picked
/// edge or face gives an axis, is checked by the handler, which has the design.
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveAxis(pub Value);
impl<'de> Deserialize<'de> for RevolveAxis {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
model_axis_problem(&v)
} else {
match &v {
Value::String(_) => None,
Value::Object(_) => revolve_axis_object_problem(&v),
_ => Some(whole(REVOLVE_AXIS_MESSAGE)),
}
};
match problem {
Some(why) => Err(D::Error::custom(why)),
None => Ok(RevolveAxis(v)),
}
}
}
impl JsonSchema for RevolveAxis {
fn schema_name() -> Cow<'static, str> {
"RevolveAxis".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let origin = generator.subschema_for::<PointExpr3>();
let model = json!({"anyOf": [{"required": ["edge"]}, {"required": ["face"]}, {"required": ["type"], "properties": {"type": {"enum": ["edge", "face"]}}}]});
json_schema!({
"description": "The axis to turn about: a sketch line's id or name (a line of the profile's sketch wins over a construction axis of the same name); x or y, the sketch's own axes; X, Y or Z, the world axes (any other case is a name); a construction axis's name, which the revolve follows when it is edited; {\"type\": \"world\", \"axis\": X, Y or Z}, as timeline.inspect gives one back; {\"origin\"?: [x, y, z] (numbers or length expressions; default: the world origin), \"dir\": [x, y, z]}, which the revolve follows when parameters change; or an edge or face of a body ({\"edge\": [x, y, z]} or {\"face\": [x, y, z]}, a point on a straight or circular edge, or on a cylinder, cone or torus face, or as saved: {\"type\": \"edge\" or \"face\", \"at\", \"name\"?}), about whose axis it turns, followed as the model changes.",
"anyOf": [
{"type": "string"},
{"type": "object", "properties": {"type": {"enum": ["world"]}, "axis": {"enum": ["X", "Y", "Z"]}}, "required": ["type", "axis"]},
{
"type": "object",
"properties": {"origin": origin, "dir": point},
"required": ["dir"],
"not": {"anyOf": [model, {"required": ["type"], "properties": {"type": {"enum": ["world"]}}}]}
},
picked_at("face", &point),
picked_at("edge", &point),
{"type": "object", "properties": {"type": {"enum": ["edge", "face"]}}, "required": ["type"]}
]
})
}
}
/// Revolve's `direction`: only `symmetric` (any case, not trimmed) does anything; the handler
/// ignores every other value.
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveDirection;
impl<'de> Deserialize<'de> for RevolveDirection {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if s.eq_ignore_ascii_case("symmetric") => Ok(RevolveDirection),
_ => Err(D::Error::custom("must be symmetric")),
}
}
}
impl JsonSchema for RevolveDirection {
fn schema_name() -> Cow<'static, str> {
"RevolveDirection".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["symmetric"],
"symmetric: the angle is split either side of the sketch plane, whichever way the axis points. The same as `symmetric: true`. Matching ignores case.",
)
}
}
/// Revolve's angular start, as the handler reads `convention` (serde, case-sensitive).
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveConventionParam(pub solidcraft_doc::RevolveConvention);
impl<'de> Deserialize<'de> for RevolveConventionParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
serde_json::from_value(Value::deserialize(d)?)
.map(RevolveConventionParam)
.map_err(|_| D::Error::custom("must be sketch_normal or legacy_axis_start"))
}
}
impl JsonSchema for RevolveConventionParam {
fn schema_name() -> Cow<'static, str> {
"RevolveConvention".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["sketch_normal", "legacy_axis_start"],
"sketch_normal (the default): the angle is measured from the sketch plane, turning about the axis as the sketch's normal sweeps. legacy_axis_start keeps the angular start older designs had.",
)
}
}
/// A construction plane's name, which `features::check_plane_name` refuses when it names an
/// origin plane. Strict gives the same message but names the command that was called, where the
/// handler (`add_feature`, which isn't given it) says `construct.plane`.
-6
View File
@@ -7,11 +7,7 @@ doc.recover
doc.recovery_discard
doc.recovery_list
face.delete
file.insert_mesh
file.insert_step
file.new
file.save_mesh
file.slicers
motion.export_animation
motion.trace
motion.trace.edit
@@ -23,7 +19,6 @@ render.preflight
render.scene
render.start
sheet.export_dxf
solid.align
solid.coil
solid.draft
solid.emboss
@@ -37,7 +32,6 @@ solid.pattern.path
solid.pattern.rectangular
solid.press_pull
solid.replace_face
solid.revolve
solid.shell
solid.thread
surface.blend
+12 -5
View File
@@ -16,6 +16,7 @@ pub(crate) fn imported_box() -> Session {
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: Some([0.2, 0.5, 0.8]),
corner_normals: Default::default(),
}],
},
Some("Imported mesh"),
@@ -41,9 +42,14 @@ fn edits_share_an_imported_meshs_triangles_with_undo() {
run(&mut s, "edit.undo", json!({}));
let (p, t) = arrays(&s);
assert!(std::sync::Arc::ptr_eq(&p, &positions) && std::sync::Arc::ptr_eq(&t, &triangles), "shared after undo");
let saved =
serde_json::to_value(MeshData { name: "M".into(), positions: vec![1.5, 2.0, 3.0].into(), triangles: vec![0, 0, 0].into(), color: None })
.unwrap();
let saved = serde_json::to_value(MeshData {
name: "M".into(),
positions: vec![1.5, 2.0, 3.0].into(),
triangles: vec![0, 0, 0].into(),
color: None,
corner_normals: Default::default(),
})
.unwrap();
assert_eq!(saved, json!({"name":"M","positions":[1.5,2.0,3.0],"triangles":[0,0,0]}));
let back: MeshData = serde_json::from_value(saved).unwrap();
assert_eq!(&back.positions[..], &[1.5, 2.0, 3.0]);
@@ -235,7 +241,7 @@ fn send_to_slicer_needs_a_slicer_and_bodies() {
/// The chosen-programs list is process-wide: tests that set it take turns.
#[cfg(unix)]
static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// `file.save_mesh` with a `slicer` writes where the caller said and hands that file over, by the
/// same steps as `file.send_to_slicer`.
@@ -271,7 +277,7 @@ fn save_mesh_with_a_chosen_program_writes_the_file_where_asked_and_sends_it_ther
/// Puts back what the tests of the send commands make up, even when one fails.
#[cfg(unix)]
struct MadeUp;
pub(crate) struct MadeUp;
#[cfg(unix)]
impl Drop for MadeUp {
@@ -563,6 +569,7 @@ fn open_in_easyprint_reports_a_host_that_cannot_start_and_a_model_over_the_limit
positions: vec![0., 0., 0., 1., 0., 0., 0., 1., 0.].into(),
triangles: [0u32, 1, 2].repeat(2_100_000).into(),
color: None,
corner_normals: Default::default(),
}],
},
Some("Imported mesh"),
+57
View File
@@ -1528,3 +1528,60 @@ fn a_modelled_thread_leaves_the_next_hole_a_cylinder_to_thread() {
assert!(t3.is_ok(), "gap {gap}: the second hole takes a modelled thread: {t3:?}");
}
}
/// #13505: a tapped hole drilled across two slots has its wall in three pieces (z 30..40, 15..25
/// and 5..10). The thread runs along every piece the hole reaches, cosmetic or modelled, and a
/// thread depth runs on through the pieces from the first one's start.
#[test]
fn a_tapped_hole_across_slots_is_threaded_along_every_piece_of_its_wall() {
let slotted = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 30, "width": 30, "height": 40, "body_name": "Part"}));
for z in [25, 10] {
run(&mut s, "solid.box", json!({"corner": [-1, -1, z], "length": 32, "width": 21, "height": 5, "operation": "cut", "targets": ["Part"]}));
}
s
};
let hole = |s: &mut Session, extra: Value| {
let mut ask = json!({"position": [15, 10, 40], "direction": [0, 0, -1], "thread": "M4", "depth": 35});
ask.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
run(s, "solid.hole", ask);
};
let spans = |s: &Session| {
let mut v: Vec<(f64, f64)> = s
.model
.state()
.threads
.iter()
.map(|t| {
let (a, b) = ((t.axis_point + t.axis * t.start).z, (t.axis_point + t.axis * t.end).z);
(a.min(b), a.max(b))
})
.collect();
v.sort_by(|a, b| b.0.total_cmp(&a.0));
v
};
let near = |got: Vec<(f64, f64)>, want: &[(f64, f64)]| {
assert_eq!(got.len(), want.len(), "{got:?} vs {want:?}");
for (g, w) in got.iter().zip(want) {
assert!((g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6, "{got:?} vs {want:?}");
}
};
let mut s = slotted();
hole(&mut s, json!({}));
near(spans(&s), &[(30.0, 40.0), (15.0, 25.0), (5.0, 10.0)]);
// 22 mm from the entry: on through the first slot, stopping in the second piece.
let mut s = slotted();
hole(&mut s, json!({"thread_depth": 22}));
near(spans(&s), &[(30.0, 40.0), (18.0, 25.0)]);
// Modelled, every piece of the wall is grooved: each loses material the cosmetic one keeps.
let mut s = slotted();
hole(&mut s, json!({"modeled": true}));
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty());
let mut plain = slotted();
hole(&mut plain, json!({}));
for (z0, z1) in [(30.0, 40.0), (15.0, 25.0), (5.0, 10.0)] {
let (cut, drilled) = (volume_between(&s, "Part", (z0, z1), None), volume_between(&plain, "Part", (z0, z1), None));
assert!(cut < drilled - 1.0, "z {z0}..{z1}: {cut} vs {drilled}");
}
}
+112
View File
@@ -323,6 +323,118 @@ pub struct MassProperties {
}
impl Mesh {
/// Generate angle-weighted corner normals without changing positions or connectivity.
/// Only opposite, two-face manifold edges join a smoothing fan. A group of zero
/// stays flat; different groups, boundaries and creases never share a normal.
/// Degenerate faces do not join or weight a fan; their corner metadata uses
/// a unit fallback so mesh-body validation can discard repeated-index faces.
/// `groups == None` is an explicitly requested crease policy for unannotated meshes.
pub fn crease_corner_normals(&self, angle: f64, groups: Option<&[u32]>) -> Result<Vec<[Vec3; 3]>, String> {
if !angle.is_finite() || !(0.0..=std::f64::consts::PI).contains(&angle) || groups.is_some_and(|g| g.len() != self.triangles.len()) {
return Err("invalid mesh smoothing angle or group count".into());
}
let corners = self.triangles.len().checked_mul(3).ok_or("mesh smoothing size overflow")?;
let mut parent = Vec::new();
parent.try_reserve_exact(corners).map_err(|_| "could not reserve mesh smoothing fans")?;
parent.extend(0..corners);
let mut weld = std::collections::HashMap::new();
weld.try_reserve(self.positions.len()).map_err(|_| "could not reserve mesh smoothing vertices")?;
let mut canonical = Vec::new();
canonical.try_reserve_exact(self.positions.len()).map_err(|_| "could not reserve mesh smoothing vertex indices")?;
for p in &self.positions {
if !p.is_finite() {
return Err("non-finite mesh smoothing position".into());
}
let next = weld.len();
canonical.push(*weld.entry([(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]).or_insert(next));
}
let mut normals = Vec::new();
normals.try_reserve_exact(self.triangles.len()).map_err(|_| "could not reserve mesh smoothing normals")?;
// A third incident face makes an edge non-manifold and prevents a join.
let mut edges = std::collections::HashMap::<(usize, usize), (usize, Option<usize>, usize)>::new();
edges.try_reserve(corners).map_err(|_| "could not reserve mesh smoothing edges")?;
for (i, t) in self.triangles.iter().enumerate() {
let [a, b, c] = t.map(|v| self.positions.get(v as usize).copied());
let (Some(a), Some(b), Some(c)) = (a, b, c) else { return Err("mesh smoothing index is missing".into()) };
let n = (b - a)
.cross(c - a)
.normalized()
.filter(|n| n.is_finite() && [b - a, c - b, a - c].iter().all(|edge| edge.normalized().is_some_and(|v| v.is_finite())));
let Some(n) = n else {
// An unusable face must not turn its neighbours' shared edge into
// a non-manifold edge or contribute a fabricated fan direction.
normals.push(Vec3::ZERO);
continue;
};
normals.push(n);
for k in 0..3 {
let (a, b) = (canonical[t[k] as usize], canonical[t[(k + 1) % 3] as usize]);
edges
.entry((a.min(b), a.max(b)))
.and_modify(|e| {
e.1 = Some(i * 3 + k);
e.2 += 1;
})
.or_insert((i * 3 + k, None, 1));
}
}
fn root(parent: &mut [usize], mut k: usize) -> usize {
while parent[k] != k {
parent[k] = parent[parent[k]];
k = parent[k];
}
k
}
let cosine = angle.cos();
for (_, (first, second, count)) in edges {
let Some(second) = second.filter(|_| count == 2) else { continue };
let (a, b) = (first / 3, second / 3);
if normals[a].dot(normals[b]) < cosine || groups.is_some_and(|g| g[a] == 0 || g[a] != g[b]) {
continue;
}
let (ak, bk) = (first % 3, second % 3);
// Consistent winding is required; do not smooth across an ambiguous join.
if canonical[self.triangles[a][ak] as usize] != canonical[self.triangles[b][(bk + 1) % 3] as usize]
|| canonical[self.triangles[a][(ak + 1) % 3] as usize] != canonical[self.triangles[b][bk] as usize]
{
continue;
}
for (x, y) in [(first, b * 3 + (bk + 1) % 3), (a * 3 + (ak + 1) % 3, second)] {
let (x, y) = (root(&mut parent, x), root(&mut parent, y));
parent[y] = x;
}
}
let mut sums = Vec::new();
sums.try_reserve_exact(corners).map_err(|_| "could not reserve mesh smoothing sums")?;
sums.resize(corners, Vec3::ZERO);
for (i, t) in self.triangles.iter().enumerate() {
if normals[i] == Vec3::ZERO {
continue;
}
for k in 0..3 {
let p = self.positions[t[k] as usize];
let a = (self.positions[t[(k + 1) % 3] as usize] - p).normalized().ok_or("degenerate smoothing corner")?;
let b = (self.positions[t[(k + 2) % 3] as usize] - p).normalized().ok_or("degenerate smoothing corner")?;
let weight = a.dot(b).clamp(-1.0, 1.0).acos();
let r = root(&mut parent, i * 3 + k);
sums[r] += normals[i] * weight;
}
}
let mut out = Vec::new();
out.try_reserve_exact(self.triangles.len()).map_err(|_| "could not reserve mesh shading result")?;
for (i, n) in normals.into_iter().enumerate() {
if n == Vec3::ZERO {
out.push([Vec3::Z; 3]);
continue;
}
out.push(std::array::from_fn(|k| {
let r = root(&mut parent, i * 3 + k);
sums[r].normalized().filter(|v| v.dot(n) > 0.0).unwrap_or(n)
}));
}
Ok(out)
}
/// [`MassProperties`] of a closed mesh, by the divergence theorem over the tetrahedra its
/// triangles make with the origin. `None` unless every edge (by vertex index: weld first) is
/// shared by exactly two triangles that cross it in opposite directions (closed, consistently
+64
View File
@@ -21,6 +21,70 @@ fn cube_mesh(s: f64) -> Mesh {
}
}
#[test]
fn crease_normals_respect_angles_groups_and_duplicate_vertex_seams() {
let theta = 30f64.to_radians();
let mesh = Mesh {
positions: vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::new(0., -theta.cos(), theta.sin())],
triangles: vec![[0, 1, 2], [1, 0, 3]],
..Default::default()
};
let flat = mesh.crease_corner_normals(20f64.to_radians(), None).unwrap();
assert!((flat[0][0] - Vec3::Z).len() < 1e-12);
assert!((flat[1][1] - Vec3::new(0., theta.sin(), theta.cos())).len() < 1e-12);
let smooth = mesh.crease_corner_normals(40f64.to_radians(), None).unwrap();
let expected = Vec3::new(0., (theta / 2.).sin(), (theta / 2.).cos());
assert!((smooth[0][0] - expected).len() < 1e-12);
assert_eq!(smooth[0][0], smooth[1][1]);
assert_eq!(mesh.crease_corner_normals(1., Some(&[1, 2])).unwrap(), flat);
assert_eq!(mesh.crease_corner_normals(1., Some(&[0, 0])).unwrap(), flat);
assert_eq!(mesh.crease_corner_normals(1., Some(&[7, 7])).unwrap(), smooth);
let split = Mesh {
positions: mesh.triangles.iter().flatten().map(|&i| mesh.positions[i as usize]).collect(),
triangles: vec![[0, 1, 2], [3, 4, 5]],
..Default::default()
};
assert_eq!(split.crease_corner_normals(1., None).unwrap(), smooth);
assert!(mesh.crease_corner_normals(f64::NAN, None).is_err());
assert!(mesh.crease_corner_normals(1., Some(&[1])).is_err());
let cube = cube_mesh(1.);
let normals = cube.crease_corner_normals(45f64.to_radians(), None).unwrap();
for (t, n) in cube.triangles.iter().zip(normals) {
let [a, b, c] = t.map(|i| cube.positions[i as usize]);
let face = (b - a).cross(c - a).normalized().unwrap();
for n in n {
assert!((n - face).len() < 1e-12);
}
}
}
#[test]
fn crease_normals_skip_degenerate_faces_without_breaking_valid_fans() {
let theta = 30f64.to_radians();
let mut mesh = Mesh {
positions: vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::new(0., -theta.cos(), theta.sin())],
triangles: vec![[0, 1, 2], [1, 0, 3]],
..Default::default()
};
let expected = mesh.crease_corner_normals(40f64.to_radians(), None).unwrap();
// Repeated indices, coincident vertices and distinct collinear vertices all
// touch the healthy pair's shared edge. None may break that smoothing fan.
mesh.positions.extend([Vec3::X, Vec3::X * 2.]);
mesh.triangles.extend([[0, 1, 1], [0, 1, 4], [0, 1, 5]]);
let groups = vec![7; mesh.triangles.len()];
for groups in [None, Some(groups.as_slice())] {
let normals = mesh.crease_corner_normals(40f64.to_radians(), groups).unwrap();
assert_eq!(&normals[..2], expected.as_slice());
assert_eq!(&normals[2..], &[[Vec3::Z; 3]; 3]);
assert!(normals.iter().flatten().all(|n| n.is_finite() && (n.len() - 1.).abs() < 1e-12));
}
mesh.triangles.push([0, 1, u32::MAX]);
assert!(mesh.crease_corner_normals(1., None).unwrap_err().contains("index is missing"));
mesh.triangles.pop();
mesh.positions[0] = Vec3::new(f64::INFINITY, 0., 0.);
assert!(mesh.crease_corner_normals(1., None).unwrap_err().contains("non-finite"));
}
#[test]
fn display_edge_masks_keep_identity_and_break_only_certified_sharp_patches() {
let mut mesh =
+35 -42
View File
@@ -30,9 +30,8 @@ pub(super) fn preview_divisor(options: &JobOptions, completed: u32, budget: Opti
// The shader divides a finished pixel exactly once, then leaves that mean alone.
Ok(if production < target { production as f32 } else { 1.0 })
}
pub(super) fn preview_paths(task: &Task, budgets: Option<&[u8]>) -> Result<u64> {
pub(super) fn preview_paths(task: &Task, pixels: u64, budgets: Option<&[u8]>) -> Result<u64> {
let options = task.options.adaptive.ok_or_else(|| internal("missing adaptive preview options"))?;
let pixels = u64::from(task.request.width) * u64::from(task.request.height);
if task.sample <= options.pilot_samples {
return Ok(pixels * u64::from(task.sample));
}
@@ -62,44 +61,38 @@ pub(super) fn report(task: &Task) -> Result<SamplingReport> {
transmission_strata: task.options.transmission_samples,
};
let Some(options) = task.options.adaptive else { return Ok(report) };
let bytes = pixels * 16;
let context = &task.context;
let scopes = scopes(&context.device);
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("adaptive budget report") });
encoder.copy_buffer_to_buffer(&task.accumulation, bytes * 2, &task.readback, 0, bytes);
let submission = context.queue.submit([encoder.finish()]);
checked(scopes)?;
let slice = task.readback.slice(0..bytes);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |result| {
let _ = tx.send(result);
});
context.wait(submission, TIMEOUT)?;
rx.try_recv().map_err(gpu)?.map_err(gpu)?;
let result = (|| {
let data = slice.get_mapped_range().map_err(gpu)?;
report.maximum_production_samples = 0;
report.production_samples = 0;
report.pixels_at_cap = 0;
for (index, record) in data.as_chunks::<16>().0.iter().enumerate() {
if index % 4096 == 0 {
task.cancel.check()?;
}
let value = f32::from_le_bytes([record[0], record[1], record[2], record[3]]);
if !value.is_finite() || value.fract() != 0.0 || value < options.min_samples as f32 || value > task.options.samples as f32 {
return Err(JobError::new(ErrorCode::Internal, "invalid GPU adaptive production count"));
}
let count = value as u32;
report.minimum_production_samples = report.minimum_production_samples.min(count);
report.maximum_production_samples = report.maximum_production_samples.max(count);
report.production_samples += u64::from(count);
report.pixels_at_cap += u64::from(count == task.options.samples);
}
report.primary_path_evaluations =
pixels * u64::from(report.pilot_samples_per_pixel) + report.production_samples * u64::from(report.transmission_strata);
task.cancel.check()?;
Ok(report)
})();
task.readback.unmap();
result
report.maximum_production_samples = 0;
report.production_samples = 0;
report.pixels_at_cap = 0;
for page in &task.pages {
let bytes = u64::from(page.pixels) * 16;
task.readback_data(
bytes,
|encoder| {
encoder.copy_buffer_to_buffer(&page.accumulation, bytes * 2, &task.readback, 0, bytes);
Ok(())
},
|data| {
for (index, record) in data.as_chunks::<16>().0.iter().enumerate() {
if index % 4096 == 0 {
task.cancel.check()?;
}
let value = f32::from_le_bytes([record[0], record[1], record[2], record[3]]);
if !value.is_finite() || value.fract() != 0.0 || value < options.min_samples as f32 || value > task.options.samples as f32 {
return Err(JobError::new(ErrorCode::Internal, "invalid GPU adaptive production count"));
}
let count = value as u32;
report.minimum_production_samples = report.minimum_production_samples.min(count);
report.maximum_production_samples = report.maximum_production_samples.max(count);
report.production_samples += u64::from(count);
report.pixels_at_cap += u64::from(count == task.options.samples);
}
Ok(())
},
)?;
}
report.primary_path_evaluations =
pixels * u64::from(report.pilot_samples_per_pixel) + report.production_samples * u64::from(report.transmission_strata);
task.cancel.check()?;
Ok(report)
}
+275 -28
View File
@@ -1,8 +1,11 @@
//! Optional output filtering; never writes the Monte Carlo accumulation buffer.
use super::{Context, Result, SHADER, TIMEOUT, Task, checked, placeholder_bind, scopes, warm_up};
use solidcraft_render::job::{DenoiseDiagnostics, DenoiseGuideClass, resolve_denoise_class};
use std::sync::atomic::{AtomicBool, Ordering};
use wgpu::util::DeviceExt;
pub(super) const BYTES_PER_PIXEL: u64 = 80; // three guide vec4s and two colour vec4s
pub(super) const GUIDE_BYTES_PER_PIXEL: u64 = 96; // two complete surface guides
pub(super) const BYTES_PER_PIXEL: u64 = GUIDE_BYTES_PER_PIXEL + 32; // two colour vec4s
const FILTER: &str = include_str!("denoise.wgsl");
const GUIDES: &str = include_str!("denoise_guides.wgsl");
#[cfg(test)]
@@ -14,6 +17,7 @@ mod tests;
pub(super) struct Pipelines {
guide: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
classify: wgpu::ComputePipeline,
filter_layout: wgpu::BindGroupLayout,
}
@@ -66,10 +70,19 @@ impl Pipelines {
compilation_options: Default::default(),
cache: None,
});
let classify = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("denoise guide classes"),
layout: Some(&layout),
module: &shader,
entry_point: Some("guide_classes"),
compilation_options: Default::default(),
cache: None,
});
checked(error_scopes)?;
warm_up(device, &context.queue, &guide, &placeholder_bind(device, &context.layout, 7)?, &context.healthy)?;
warm_up(device, &context.queue, &filter, &placeholder_bind(device, &filter_layout, 4)?, &context.healthy)?;
Ok(Self { guide, filter, filter_layout })
warm_up(device, &context.queue, &classify, &placeholder_bind(device, &filter_layout, 4)?, &context.healthy)?;
Ok(Self { guide, filter, classify, filter_layout })
}
fn shared(context: &Context) -> Result<&Self> {
@@ -83,17 +96,30 @@ impl Pipelines {
pub(super) struct Resources {
adaptive: Option<AdaptiveInput>,
layout: Option<super::image_pages::Layout>,
input: Option<wgpu::Buffer>,
guides_ready: AtomicBool,
guide_pipeline: wgpu::ComputePipeline,
guide_bind: wgpu::BindGroup,
filter_pipeline: wgpu::ComputePipeline,
classify_pipeline: wgpu::ComputePipeline,
filter_uniform: wgpu::Buffer,
filter_binds: Vec<wgpu::BindGroup>,
output: wgpu::Buffer,
temporary: wgpu::Buffer,
// Retained for diagnostic tests; bind groups also retain the guide allocation.
_guides: wgpu::Buffer,
}
type Window = super::image_pages::Window;
fn full_window(task: &Task) -> Window {
let pixels = task.request.width * task.request.height;
Window { start: 0, pixels, core_start: 0, core_pixels: pixels }
}
impl Resources {
#[cfg(test)]
pub(super) fn new(
context: &Context,
path_uniform: &wgpu::Buffer,
@@ -101,6 +127,29 @@ impl Resources {
accumulation: &wgpu::Buffer,
size: u64,
adaptive_preview: bool,
) -> Result<Self> {
Self::build(context, path_uniform, scene, accumulation, size, adaptive_preview, None)
}
pub(super) fn new_for_layout(
context: &Context,
path_uniform: &wgpu::Buffer,
scene: [&wgpu::Buffer; 5],
accumulation: &wgpu::Buffer,
adaptive_preview: bool,
layout: super::image_pages::Layout,
) -> Result<Self> {
Self::build(context, path_uniform, scene, accumulation, u64::from(layout.max_window_pixels()) * 16, adaptive_preview, Some(layout))
}
fn build(
context: &Context,
path_uniform: &wgpu::Buffer,
scene: [&wgpu::Buffer; 5],
accumulation: &wgpu::Buffer,
size: u64,
adaptive_preview: bool,
layout: Option<super::image_pages::Layout>,
) -> Result<Self> {
let pipelines = Pipelines::shared(context)?;
let device = &context.device;
@@ -113,9 +162,11 @@ impl Resources {
mapped_at_creation: false,
})
};
let guides = buffer("denoise first-hit guides", size * 3);
let output = buffer("denoise filtered colour", size);
let temporary = buffer("denoise intermediate colour", size);
let input = layout.filter(|layout| layout.is_paged()).map(|layout| buffer("denoise gathered film window", size * u64::from(layout.planes)));
let accumulation = input.as_ref().unwrap_or(accumulation);
let guides = buffer("denoise first-hit guide window", size * (GUIDE_BYTES_PER_PIXEL / 16));
let output = buffer("denoise filtered colour window", size);
let temporary = buffer("denoise intermediate colour window", size);
let [triangles, nodes, environment, textures, failures] = scene;
let entries: Vec<_> = [path_uniform, triangles, nodes, &guides, environment, textures, failures]
.into_iter()
@@ -126,7 +177,7 @@ impl Resources {
device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("denoise guides"), layout: &context.layout, entries: &entries });
let filter_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("denoise parameters"),
contents: &[0; 32],
contents: &[0; 48],
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let filter_binds = [(accumulation, &output), (&output, &temporary), (&temporary, &output)]
@@ -148,37 +199,81 @@ impl Resources {
checked(error_scopes)?;
Ok(Self {
adaptive,
layout,
input,
guides_ready: AtomicBool::new(false),
guide_pipeline: pipelines.guide.clone(),
guide_bind,
filter_pipeline: pipelines.filter.clone(),
classify_pipeline: pipelines.classify.clone(),
filter_uniform,
filter_binds,
output,
temporary,
_guides: guides,
})
}
pub(super) fn output(&self) -> &wgpu::Buffer {
&self.output
pub(super) fn output(&self, passes: u32) -> &wgpu::Buffer {
if passes == 2 { &self.temporary } else { &self.output }
}
#[cfg(test)]
pub(super) fn run(&self, task: &Task) -> Result<()> {
self.run_with_input(task, false)
self.prepare_window(task, full_window(task), false)?;
self.run_filter(task, false)
}
pub(super) fn run_adaptive_preview(&self, task: &Task) -> Result<()> {
self.adaptive.as_ref().ok_or_else(|| super::gpu("missing adaptive denoise preview input"))?.normalize(task)?;
self.run_with_input(task, true)
fn prepare_window(&self, task: &Task, window: Window, preview: bool) -> Result<()> {
if let Some(input) = &self.input {
let context = &task.context;
context.ensure_available()?;
let submission = super::checked_creation(&context.device, || {
let mut encoder =
context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("denoise film window gather") });
let planes = if preview { task.page_layout.planes } else { 1 };
for plane in 0..planes {
task.copy_film_range(&mut encoder, window.start, window.pixels, plane, input, u64::from(plane) * u64::from(window.pixels) * 16)?;
}
Ok(context.queue.submit([encoder.finish()]))
})?;
context.wait(submission, TIMEOUT)?;
}
// The immutable scene permits caching only when the entire guide image
// is resident. A paged job reuses this allocation for every window.
if self.input.is_some() || !self.guides_ready.load(Ordering::Relaxed) {
self.generate_guides_window(task, window)?;
self.guides_ready.store(true, Ordering::Relaxed);
}
if preview {
self.adaptive.as_ref().ok_or_else(|| super::gpu("missing adaptive denoise preview input"))?.normalize_window(task, window)?;
}
task.cancel.check()
}
fn run_with_input(&self, task: &Task, preview: bool) -> Result<()> {
let pixels = task.request.width * task.request.height;
#[cfg(test)]
fn generate_guides(&self, task: &Task) -> Result<()> {
self.generate_guides_window(task, full_window(task))
}
fn generate_guides_window(&self, task: &Task, window: Window) -> Result<()> {
// Camera dimensions, footprints and random keys use global pixels.
// Only guide storage uses the current window's local index.
let guide_flags = u32::from(task.photons.is_some()) | (u32::from(task.optical_resolve.is_some()) << 1);
let mut budget = super::dispatch::Budget::default();
let mut offset = 0;
while offset < pixels {
while offset < window.pixels {
task.cancel.check()?;
let count = budget.count(pixels - offset);
let count = budget.count(window.pixels - offset);
let mut parameters = task.parameters(count)?;
if let Some(bytes) = parameters.get_mut(12..16) {
bytes.copy_from_slice(&offset.to_le_bytes());
let global = window.start + offset;
for (start, word) in
[(12, global), (28, guide_flags), (super::PARAMETER_BYTES - 16, window.start), (super::PARAMETER_BYTES - 12, window.pixels)]
{
parameters
.get_mut(start..start + 4)
.ok_or_else(|| super::gpu("missing denoise window parameters"))?
.copy_from_slice(&word.to_le_bytes());
}
task.context.queue.write_buffer(&task.uniform, 0, &parameters);
let started = std::time::Instant::now();
@@ -186,12 +281,151 @@ impl Resources {
budget.observe(count, started.elapsed());
offset += count;
}
self.run_filter(task, preview)
task.cancel.check()
}
/// Assemble only each core after filtering its complete dependency halo.
/// The source film and every pixel/sample identity remain unchanged.
pub(super) fn read_filtered(&self, task: &Task, preview: bool) -> Result<Vec<[f32; 4]>> {
let Some(layout) = self.layout.filter(|layout| layout.is_paged()) else {
let window = full_window(task);
self.prepare_window(task, window, preview)?;
self.run_filter_window(task, preview, window)?;
return task.read_linear_buffer(self.output(task.options.denoise_settings.passes));
};
let mut values = super::scene::reserved((task.request.width * task.request.height) as usize)?;
let source = self.output(task.options.denoise_settings.passes);
for window in layout.windows() {
self.prepare_window(task, window, preview)?;
self.run_filter_window(task, preview, window)?;
let bytes = u64::from(window.core_pixels) * 16;
task.readback_data(
bytes,
|encoder| {
encoder.copy_buffer_to_buffer(source, u64::from(window.core_start - window.start) * 16, &task.readback, 0, bytes);
Ok(())
},
|data| {
for (index, pixel) in data.as_chunks::<16>().0.iter().enumerate() {
if index % 4096 == 0 {
task.cancel.check()?;
}
let mut value = [0.0; 4];
for (value, bytes) in value.iter_mut().zip(pixel.as_chunks::<4>().0) {
*value = f32::from_le_bytes(*bytes) / task.normalization_samples() as f32;
if !value.is_finite() || *value < 0.0 {
return Err(super::gpu("nonfinite or negative filtered path radiance"));
}
}
values.push(value);
}
Ok(())
},
)?;
}
if values.len() != (task.request.width * task.request.height) as usize {
return Err(super::gpu("denoise windows do not cover the complete trace grid"));
}
task.validate_failures(None)?;
Ok(values)
}
/// Each core contributes once; filter halos never enter the category counts
/// or the conservative X2 category resolve.
pub(super) fn diagnostics(&self, task: &Task) -> Result<(DenoiseDiagnostics, Vec<u8>)> {
let output = &task.output_request;
let capacity =
usize::try_from(u64::from(output.width) * u64::from(output.height) * 4).map_err(|_| super::gpu("denoise mask exceeds address space"))?;
let mut rgba = super::scene::reserved(capacity)?;
let mut report = DenoiseDiagnostics::new(task.request.width, task.request.height);
if let Some(layout) = self.layout.filter(|layout| layout.is_paged()) {
for window in layout.windows() {
self.diagnostic_window(task, window, &mut report, &mut rgba)?;
}
} else {
self.diagnostic_window(task, full_window(task), &mut report, &mut rgba)?;
}
if rgba.len() != capacity {
return Err(super::gpu("denoise diagnostic windows do not cover the final grid"));
}
task.cancel.check()?;
Ok((report, rgba))
}
fn diagnostic_window(&self, task: &Task, window: Window, report: &mut DenoiseDiagnostics, rgba: &mut Vec<u8>) -> Result<()> {
self.prepare_window(task, window, false)?;
let bind = self.filter_binds.first().ok_or_else(|| super::gpu("missing diagnostic bind group"))?;
let mut budget = super::dispatch::Budget::default();
let mut offset = 0;
while offset < window.pixels {
task.cancel.check()?;
let count = budget.count(window.pixels - offset);
let mut bytes = Vec::with_capacity(48);
for word in
[task.request.width, task.request.height, 0, offset, count, task.normalization_samples(), 0, 0, window.start, window.pixels, 0, 0]
{
bytes.extend_from_slice(&word.to_le_bytes());
}
task.context.queue.write_buffer(&self.filter_uniform, 0, &bytes);
let started = std::time::Instant::now();
dispatch(&task.context, &self.classify_pipeline, bind, count)?;
budget.observe(count, started.elapsed());
offset += count;
}
let scale = task.options.supersampling as usize;
let width = task.request.width as usize;
let first_row = window.core_start as usize / width;
let core_rows = window.core_pixels as usize / width;
if !(1..=2).contains(&scale) || !first_row.is_multiple_of(scale) || !core_rows.is_multiple_of(scale) {
return Err(super::gpu("diagnostic window does not own complete supersampling cells"));
}
let byte_count = u64::from(window.core_pixels) * 16;
task.readback_data(
byte_count,
|encoder| {
encoder.copy_buffer_to_buffer(&self.output, u64::from(window.core_start - window.start) * 16, &task.readback, 0, byte_count);
Ok(())
},
|data| {
for y in 0..core_rows / scale {
task.cancel.check()?;
for x in 0..task.output_request.width as usize {
let mut classes = [DenoiseGuideClass::Unavailable; 4];
for dy in 0..scale {
for dx in 0..scale {
let pixel = (y * scale + dy) * width + x * scale + dx;
let bytes: [u8; 4] = data
.get(pixel * 16..pixel * 16 + 4)
.and_then(|bytes| bytes.try_into().ok())
.ok_or_else(|| super::gpu("missing denoise guide classification"))?;
let code = f32::from_le_bytes(bytes);
if !code.is_finite() || code < 0.0 || code.fract() != 0.0 {
return Err(super::gpu("invalid denoise guide classification"));
}
let class =
DenoiseGuideClass::from_code(code as u32).ok_or_else(|| super::gpu("unknown denoise guide classification"))?;
report.record(class);
*classes.get_mut(dy * scale + dx).ok_or_else(|| super::gpu("invalid diagnostic supersample scale"))? = class;
}
}
let class = resolve_denoise_class(classes.into_iter().take(scale * scale));
rgba.extend_from_slice(&class.color());
}
}
Ok(())
},
)
}
#[cfg(test)]
fn run_filter(&self, task: &Task, preview: bool) -> Result<()> {
let pixels = task.request.width * task.request.height;
for (pass, bind) in self.filter_binds.iter().enumerate() {
self.run_filter_window(task, preview, full_window(task))
}
fn run_filter_window(&self, task: &Task, preview: bool, window: Window) -> Result<()> {
let settings = task.options.denoise_settings;
settings.validate()?;
for (pass, bind) in self.filter_binds.iter().take(settings.passes as usize).enumerate() {
let bind = if preview && pass == 0 {
&self.adaptive.as_ref().ok_or_else(|| super::gpu("missing adaptive denoise preview input"))?.filter_bind
} else {
@@ -199,11 +433,24 @@ impl Resources {
};
let mut budget = super::dispatch::Budget::default();
let mut offset = 0;
while offset < pixels {
while offset < window.pixels {
task.cancel.check()?;
let count = budget.count(pixels - offset);
let mut bytes = Vec::with_capacity(32);
for word in [task.request.width, task.request.height, 1 << pass, offset, count, task.normalization_samples(), 0, 0] {
let count = budget.count(window.pixels - offset);
let mut bytes = Vec::with_capacity(48);
for word in [
task.request.width,
task.request.height,
1 << pass,
offset,
count,
task.normalization_samples(),
(settings.strength as f32).to_bits(),
u32::from(settings.firefly_suppression),
window.start,
window.pixels,
u32::from(settings.optical_filter),
0,
] {
bytes.extend_from_slice(&word.to_le_bytes());
}
task.context.queue.write_buffer(&self.filter_uniform, 0, &bytes);
@@ -301,16 +548,16 @@ impl AdaptiveInput {
});
Self { _pixels: pixels, uniform, pipeline, bind, filter_bind }
}
fn normalize(&self, task: &Task) -> Result<()> {
fn normalize_window(&self, task: &Task, window: Window) -> Result<()> {
let options = task.options.adaptive.ok_or_else(|| super::gpu("missing adaptive preview options"))?;
let pixels = task.request.width * task.request.height;
let pixels = window.pixels;
let mut budget = super::dispatch::Budget::default();
let mut offset = 0;
while offset < pixels {
task.cancel.check()?;
let count = budget.count(pixels - offset);
let mut bytes = Vec::with_capacity(32);
for word in [task.request.width, task.request.height, task.sample, offset, count, options.pilot_samples, 0, 0] {
for word in [task.request.width, window.pixels / task.request.width, task.sample, offset, count, options.pilot_samples, 0, 0] {
bytes.extend_from_slice(&word.to_le_bytes());
}
task.context.queue.write_buffer(&self.uniform, 0, &bytes);
+75 -18
View File
@@ -1,48 +1,105 @@
// Original three-pass edge-avoiding a-trous filter inspired by Dammertz et al.
// Original bounded edge-avoiding a-trous filter inspired by Dammertz et al.
// Inputs/outputs keep accumulated linear premultiplied radiance and raw alpha.
struct FilterParams { image: vec4<u32>, work: vec4<u32> };
// window.z independently enables optical filtering; work.w remains the firefly flag.
struct FilterParams { image: vec4<u32>, work: vec4<u32>, window: vec4<u32> };
@group(0) @binding(0) var<uniform> f: FilterParams;
@group(0) @binding(1) var<storage,read> guides: array<vec4<f32>>;
@group(0) @binding(2) var<storage,read> source: array<vec4<f32>>;
@group(0) @binding(3) var<storage,read_write> filtered: array<vec4<f32>>;
fn filter_pixels()->u32 {return select(f.image.x*f.image.y,f.window.y,f.window.y!=0u);}
fn kernel(x:i32)->f32 {
if x==0 { return 6.0; }
if abs(x)==1 { return 4.0; }
return 1.0;
}
fn max_component(v:vec3<f32>)->f32 { return max(max(v.x,v.y),v.z); }
// Both optical branches must agree; body identity alone cannot preserve
// material, normal or depth edges on a single reflected object.
fn geometry_weight(a:vec4<f32>,n:vec4<f32>,p:vec4<f32>,b:vec4<f32>,m:vec4<f32>,r:vec4<f32>,step:f32)->f32 {
if a.w!=b.w {return 0.0;}
if a.w==0.0 {return 1.0;}
let da=a.xyz-b.xyz; let dp=r.xyz-p.xyz;
let plane=abs(dot(dp,n.xyz))+abs(dot(dp,m.xyz));
let footprint=max(p.w,r.w)*step;
let depth=abs(n.w-m.w)/max(8.0*footprint,0.000001);
return exp(-dot(da,da)/0.04-plane/max(0.05*footprint,0.000001)-depth)*pow(max(dot(n.xyz,m.xyz),0.0),32.0);
}
@compute @workgroup_size(64)
fn atrous(@builtin(global_invocation_id) gid:vec3<u32>) {
let pixel=f.image.w+gid.x;
if gid.x>=f.work.x || pixel>=f.image.x*f.image.y { return; }
let c=source[pixel];
let a=guides[pixel*3u]; let n=guides[pixel*3u+1u]; let p=guides[pixel*3u+2u];
if a.w==0.0 || c.w<f32(f.work.y)*0.999 { filtered[pixel]=c; return; }
let local=f.image.w+gid.x;
let pixel=f.window.x+local;
if gid.x>=f.work.x || local>=filter_pixels() || pixel>=f.image.x*f.image.y { return; }
let c=source[local];
let a=guides[local*6u]; let n=guides[local*6u+1u]; let p=guides[local*6u+2u];
// Optical correspondence is a negative, finite tag in the same slot.
let optical=a.w<0.0;
if a.w==0.0 || c.w<f32(f.work.y)*0.999 || (optical && (f.window.z==0u || f.image.z>2u)) { filtered[local]=c; return; }
let xy=vec2<i32>(i32(pixel%f.image.x),i32(pixel/f.image.x));
var mean=vec3<f32>(0.0); var total=0.0;
let samples=f32(f.work.y);
let color=c.xyz/samples;
let center_peak=max_component(color);
let first_pass=f.image.z==1u && !optical && f.work.w!=0u;
var support_count=0u;
var support_peak=0.0;
for(var y=-2;y<=2;y+=1) { for(var x=-2;x<=2;x+=1) {
let q=xy+vec2<i32>(x,y)*i32(f.image.z);
if any(q<vec2<i32>(0)) || q.x>=i32(f.image.x) || q.y>=i32(f.image.y) { continue; }
let j=u32(q.y)*f.image.x+u32(q.x);
let b=guides[j*3u]; let m=guides[j*3u+1u]; let r=guides[j*3u+2u];
let global=u32(q.y)*f.image.x+u32(q.x);
if global<f.window.x || global-f.window.x>=filter_pixels() {continue;}
let j=global-f.window.x;
let b=guides[j*6u]; let m=guides[j*6u+1u]; let r=guides[j*6u+2u];
let v=source[j];
if b.w!=a.w || abs(v.w-c.w)>samples*0.01 { continue; }
let da=a.xyz-b.xyz; let dp=r.xyz-p.xyz;
let plane=abs(dot(dp,n.xyz))+abs(dot(dp,m.xyz));
let scale=max(0.05*max(p.w,r.w)*f32(f.image.z),0.000001);
let depth=abs(n.w-m.w)/max(8.0*max(p.w,r.w)*f32(f.image.z),0.000001);
let dc=max_component(abs(color-v.xyz/samples));
let color_scale=0.25+0.5*max(max_component(color),max_component(v.xyz/samples));
let weight=kernel(x)*kernel(y)*exp(-dot(da,da)/0.04-plane/scale-depth-dc/color_scale)
*pow(max(dot(n.xyz,m.xyz),0.0),32.0);
let guide_weight=geometry_weight(a,n,p,b,m,r,f32(f.image.z))
*geometry_weight(guides[local*6u+3u],guides[local*6u+4u],guides[local*6u+5u],
guides[j*6u+3u],guides[j*6u+4u],guides[j*6u+5u],f32(f.image.z));
let tap_color=v.xyz/samples;
let tap_peak=max_component(tap_color);
// A genuine bright feature has bright compatible neighbours. Only the
// immediate 3x3 support may identify an isolated first-pass outlier.
if first_pass && abs(x)<=1 && abs(y)<=1 && (x!=0 || y!=0) && guide_weight>=0.5 {
support_count+=1u;
support_peak=max(support_peak,tap_peak);
}
let high=max(center_peak,tap_peak);
let bound=16.0*(0.25+min(center_peak,tap_peak));
var contrast=1.0;
if f.work.w!=0u && high>bound {
let ratio=bound/high;
contrast=ratio*ratio;
}
let dc=max_component(abs(color-tap_color));
let color_scale=0.25+0.5*high;
let weight=kernel(x)*kernel(y)*guide_weight*exp(-select(1.0,4.0,optical)*dc/color_scale)*contrast;
if weight>0.0 {
total+=weight;
// Online convex averaging avoids overflow of radiance times kernel.
mean+=(v.xyz-mean)*(weight/total);
}
}}
if first_pass && support_count>=5u && center_peak>16.0*(0.25+support_peak) {
let limit=2.0*support_peak+0.25;
let mean_peak=max_component(mean/samples);
if mean_peak>limit { mean*=limit/mean_peak; }
}
// The centre always contributes; retain original coverage without filtering.
filtered[pixel]=vec4<f32>(mean,c.w);
filtered[local]=vec4<f32>(mix(c.xyz,mean,bitcast<f32>(f.work.z)),c.w);
}
// Stable DenoiseGuideClass codes. This diagnostic writes only the filter scratch
// output, never accumulation or guide data. Coverage matches the filter gate.
@compute @workgroup_size(64)
fn guide_classes(@builtin(global_invocation_id) gid:vec3<u32>) {
let local=f.image.w+gid.x;
let pixel=f.window.x+local;
if gid.x>=f.work.x || local>=filter_pixels() || pixel>=f.image.x*f.image.y {return;}
let guide=guides[local*6u];
var reason=u32(guide.x);
if guide.w!=0.0 {
reason=select(1u,2u,guide.w<0.0);
if source[local].w<f32(f.work.y)*0.999 {reason=16u;}
}
filtered[local]=vec4<f32>(f32(reason),0.0,0.0,1.0);
}
+206 -29
View File
@@ -1,39 +1,216 @@
// Original deterministic first-hit guides. Uses the production intersection and
// material/normal texture evaluation, without advancing the path random stream.
// Deterministic display guides use the production intersections and resolved
// appearance without changing the path random stream or failure counters.
struct GuideLook {
albedo: vec3<f32>, normal: vec3<f32>, metallic: f32, roughness: f32,
};
struct GuideRay {
point: PrecisePoint, direction: vec3<f32>, excluded: u32, minimum: f32,
};
struct GuideInterface {
reflected: vec3<f32>, transmitted: vec3<f32>, fresnel: vec3<f32>,
};
const GUIDE_MISS: u32 = 0xffffffffu;
// Zero body tags bypass filtering. Store the exact early-return reason in the
// otherwise-unused albedo lane; positive/negative tags retain their old layout.
fn guide_bypass(base:u32,reason:u32) {
accumulation[base]=vec4<f32>(f32(reason),0.0,0.0,0.0);
}
fn guide_outward(t:Triangle)->vec3<f32> {
return unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
}
fn guide_normal(t:Triangle,bary:vec3<f32>,geometric:vec3<f32>,direction:vec3<f32>)->vec3<f32> {
let interpolated=unit(t.n0.xyz*bary.x+t.n1.xyz*bary.y+t.n2.xyz*bary.z);
let winding=select(interpolated,-interpolated,dot(interpolated,geometric)<0.0);
return select(geometric,winding,dot(winding,-direction)>0.0);
}
fn guide_look(t:Triangle,hit:Hit,direction:vec3<f32>,position:vec3<f32>,travel:f32,bounce:u32)->GuideLook {
let outward=guide_outward(t);
let geometric=select(-outward,outward,dot(outward,-direction)>=0.0);
let record=texture_record(t.optics.w);
let gradients=texture_gradients(record,geometric,direction,hit.distance,travel,bounce);
let mapped=texture_surface(record,t.base.xyz,t.parameters.x,t.parameters.y,position,gradients,(u32(t.interior.z)&2u)!=0u);
var look=t;
look.base=vec4<f32>(mapped.color,t.base.w);
look.parameters=vec4<f32>(mapped.metallic,mapped.roughness,t.parameters.zw);
look=aged_surface(look,position,outward);
let normal=guide_normal(t,hit.bary,geometric,direction);
let perturbed=texture_normal(record,mapped.normal,normal,geometric);
return GuideLook(look.base.xyz,select(normal,perturbed,dot(perturbed,-direction)>0.0),look.parameters.x,look.parameters.y);
}
fn guide_sharp(t:Triangle,look:GuideLook)->bool {
return (look.metallic>0.5 && look.roughness<0.25) || (t.coat.x>0.0 && t.coat.y<0.05);
}
fn guide_depth(position:vec3<f32>)->f32 {return dot(position-u.eye.xyz,-u.back.xyz);}
fn guide_footprint(depth:f32)->f32 {
var footprint=2.0*length(u.up.xyz)/f32(u.image.y);
if u.eye.w>0.5 {footprint*=max(depth,0.0)/u.back.w;}
return max(footprint,0.000001);
}
fn guide_write(base:u32,id:f32,look:GuideLook,position:vec3<f32>,primary_footprint:f32) {
let depth=guide_depth(position);
accumulation[base]=vec4<f32>(look.albedo,id);
accumulation[base+1u]=vec4<f32>(look.normal,depth);
accumulation[base+2u]=vec4<f32>(position,max(primary_footprint,guide_footprint(depth)));
}
fn guide_vacuum_origin(ray:CameraRay)->bool {
if u.lighting.w==0.0 {return true;}
let boundary=origin_boundary(ray.origin,ray.direction);
if !boundary.valid {return false;}
if boundary.hit.triangle==GUIDE_MISS {return true;}
let t=triangle(boundary.hit.triangle);
return t.interior.w==0.0 && dot(guide_outward(t),ray.direction)<0.0;
}
fn guide_smooth_glass(t:Triangle)->bool {
return t.base.w>0.0 && all(t.glass==vec4<f32>(0.0)) && t.interior.x==0.0 && t.interior.w==0.0;
}
fn guide_interface(t:Triangle,bary:vec3<f32>,direction:vec3<f32>,geometric:vec3<f32>,ni:f32,nt:f32)->GuideInterface {
var normal=geometric;
// The guide-only sampling.w bit zero mirrors photon geometric normals.
if u.volume_albedo.w==0.0 && (u.sampling.w&1u)==0u {normal=guide_normal(t,bary,geometric,direction);}
var fresnel=dielectric(clamp(dot(normal,-direction),0.0,1.0),ni,nt);
var transmitted=refract(direction,normal,ni/nt);
if (fresnel.z>=0.0 && dot(transmitted,geometric)>=0.0) || dot(reflect(direction,normal),geometric)<=0.0 {
normal=geometric;
fresnel=dielectric(clamp(dot(normal,-direction),0.0,1.0),ni,nt);
transmitted=refract(direction,normal,ni/nt);
}
// Keep a numerical zero from Snell so a degenerate branch is rejected.
if any(transmitted!=vec3<f32>(0.0)) {transmitted=unit(transmitted);}
return GuideInterface(reflect(direction,normal),transmitted,fresnel);
}
fn guide_launch(t:Triangle,hit:Hit,geometric:vec3<f32>,direction:vec3<f32>)->GuideRay {
if t.base.w>0.0 && t.parameters.w<0.0 {
// Closed glass continues on the precise boundary, suppressing only the
// producing triangle and keeping strictly positive adjacent crossings.
return GuideRay(surface_point_precise(t,hit.bary),direction,hit.triangle,0.0);
}
let shift=select(geometric,direction,dot(direction,geometric)<0.0)*(2.0*u.lighting.x);
if t.base.w>0.0 && (u.sampling.w&2u)!=0u {
// Optical resolve uses a split launch for thin sheets.
let point=surface_point_precise(t,hit.bary);
return GuideRay(PrecisePoint(point.hi,point.lo+shift),direction,GUIDE_MISS,u.lighting.x);
}
return GuideRay(PrecisePoint(surface_point(t,hit.bary)+shift,vec3<f32>(0.0)),direction,GUIDE_MISS,u.lighting.x);
}
@compute @workgroup_size(64)
fn denoise_guides(@builtin(global_invocation_id) gid: vec3<u32>) {
let pixel=u.image.w+gid.x;
if gid.x>=u.sampling.z || pixel>=u.image.x*u.image.y { return; }
let base=pixel*3u;
let base=film_index(pixel)*6u;
accumulation[base]=vec4<f32>(0.0);
accumulation[base+1u]=vec4<f32>(0.0);
accumulation[base+2u]=vec4<f32>(0.0);
accumulation[base+3u]=vec4<f32>(0.0);
accumulation[base+4u]=vec4<f32>(0.0);
accumulation[base+5u]=vec4<f32>(0.0);
let xy=vec2<f32>(f32(pixel%u.image.x)+0.5,f32(pixel/u.image.x)+0.5);
let uv=vec2<f32>(xy.x/f32(u.image.x)*2.0-1.0,1.0-xy.y/f32(u.image.y)*2.0);
let ray=primary_ray(uv,vec2<f32>(0.0));
let hit=trace(ray.origin,ray.direction,false);
if hit.triangle==0xffffffffu { return; }
if u.environment.w>0.0 && emitter_hit_bounded(ray.origin,ray.direction,u.lighting.x).distance<hit.distance { return; }
var t=triangle(hit.triangle);
let position=surface_point(t,hit.bary);
var geometric=unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
if dot(geometric,-ray.direction)<0.0 { geometric=-geometric; }
let map_record=texture_record(t.optics.w);
let gradients=texture_gradients(map_record,geometric,ray.direction,hit.distance,length(position-ray.origin),0u);
let mapped_surface=texture_surface(map_record,t.base.xyz,t.parameters.x,t.parameters.y,position,gradients,(u32(t.interior.z)&2u)!=0u);
t.base=vec4<f32>(mapped_surface.color,t.base.w);t.parameters=vec4<f32>(mapped_surface.metallic,mapped_surface.roughness,t.parameters.zw);
t=aged_surface(t,position,unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz)));
// Single first-hit guides cannot describe refracted or sharp reflected images.
if t.base.w>0.0 || (t.parameters.x>0.5 && t.parameters.y<0.25) || (t.coat.x>0.0 && t.coat.y<0.05) { return; }
var n=unit(t.n0.xyz*hit.bary.x+t.n1.xyz*hit.bary.y+t.n2.xyz*hit.bary.z);
if dot(n,geometric)<0.0 { n=-n; }
if dot(n,-ray.direction)<=0.0 { n=geometric; }
let mapped_normal=texture_normal(map_record,mapped_surface.normal,n,geometric);
if dot(mapped_normal,-ray.direction)>0.0 { n=mapped_normal; }
let depth=dot(position-u.eye.xyz,-u.back.xyz);
var footprint=2.0*length(u.up.xyz)/f32(u.image.y);
if u.eye.w>0.5 { footprint*=max(depth,0.0)/u.back.w; }
accumulation[base]=vec4<f32>(t.base.xyz,t.optics.w);
accumulation[base+1u]=vec4<f32>(n,depth);
accumulation[base+2u]=vec4<f32>(position,max(footprint,0.000001));
let camera=primary_ray(uv,vec2<f32>(0.0));
let primary=trace(camera.origin,camera.direction,false);
if u.environment.w>0.0 && emitter_hit_bounded(camera.origin,camera.direction,u.lighting.x).distance<primary.distance {guide_bypass(base,4u);return;}
if primary.triangle==GUIDE_MISS {guide_bypass(base,3u);return;}
if u.volume_min.w>0.0 {guide_bypass(base,8u);return;}
let t=triangle(primary.triangle);
let position=surface_point(t,primary.bary);
let outward=guide_outward(t);
let entering=dot(outward,-camera.direction)>0.0;
let geometric=select(-outward,outward,entering);
let primary_footprint=guide_footprint(guide_depth(position));
let primary_travel=length(position-camera.origin);
let closed=t.base.w>0.0 && t.parameters.w<0.0;
var reflected=vec3<f32>(0.0);
var transmitted=vec3<f32>(0.0);
var has_reflection=false;
var has_transmission=false;
if t.base.w<=0.0 {
let look=guide_look(t,primary,camera.direction,position,primary_travel,0u);
if !guide_sharp(t,look) {guide_write(base,t.optics.w,look,position,primary_footprint);return;}
if look.metallic<0.99 || look.roughness>0.05 || t.coat.x>0.0 {guide_bypass(base,5u);return;}
reflected=reflect(camera.direction,look.normal);
has_reflection=true;
} else {
if any(t.glass!=vec4<f32>(0.0)) {guide_bypass(base,5u);return;}
if t.interior.x!=0.0 {guide_bypass(base,8u);return;}
if t.interior.w!=0.0 {guide_bypass(base,6u);return;}
if closed && !entering {guide_bypass(base,6u);return;}
let bend=guide_interface(t,primary.bary,camera.direction,geometric,1.0,t.parameters.z);
reflected=bend.reflected;
let f=bend.fresnel;
has_reflection=max(f.x,f.y)>0.0;
has_transmission=f.z>=0.0;
transmitted=bend.transmitted;
if !closed && has_transmission {
transmitted=camera.direction;
// Use the same sheet weights without calling the transport helper
// that can write a failure counter on a tangent absorbing sheet.
if t.parameters.w>0.0 && !(f.z>0.0) {guide_bypass(base,10u);return;}
var attenuation=vec3<f32>(1.0);
if t.parameters.w>0.0 {attenuation=exp(-t.optics.xyz*(t.parameters.w/f.z));}
has_transmission=any(sheet_polarization(f.x,attenuation).transmission+sheet_polarization(f.y,attenuation).transmission>vec3<f32>(0.0));
}
}
if t.optics.w<1.0 || t.optics.w>511.0 {guide_bypass(base,15u);return;}
if u.eye.w>0.5 && u.right.w>0.0 {guide_bypass(base,7u);return;}
if u.sampling.y<2u || (closed && has_transmission && u.sampling.y<3u) {guide_bypass(base,9u);return;}
if !has_reflection && !has_transmission {guide_bypass(base,10u);return;}
if (has_reflection && dot(reflected,geometric)<=0.000001) || (has_transmission && dot(transmitted,geometric)>=-0.000001) {guide_bypass(base,10u);return;}
// A certified root closed entry proves vacuum along this primary segment.
// A mirror or sheet can be hit first even when a nested camera starts inside glass.
if !closed && !guide_vacuum_origin(camera) {guide_bypass(base,6u);return;}
var reflected_body=0u;
var transmitted_body=0u;
var reflected_look:GuideLook;
var reflected_position=vec3<f32>(0.0);
// Stage 0 is the reflection endpoint, 1 is the same closed body's exit,
// and 2 is the forward endpoint. One call site bounds the extra BVH work.
let forward_stage=select(2u,1u,closed);
var stage=select(forward_stage,0u,has_reflection);
var ray=guide_launch(t,primary,geometric,select(transmitted,reflected,has_reflection));
var travel=primary_travel;
var bounce=1u;
for (var event=0u;event<3u;event+=1u) {
let hit=trace_precise(ray.point.hi,ray.point.lo,ray.direction,false,ray.excluded,ray.minimum,stage==1u);
// A secondary optical branch has no surface correspondence. The primary
// pixel is still on glass/mirror, rather than direct background/emission.
if u.environment.w>0.0 && emitter_hit_precise(ray.point.hi,ray.point.lo,ray.direction,ray.minimum).distance<hit.distance {guide_bypass(base,0u);return;}
if hit.triangle==GUIDE_MISS {guide_bypass(base,0u);return;}
let next=triangle(hit.triangle);
travel+=hit.distance;
if stage==1u {
let normal=-guide_outward(next);
if next.optics.w!=t.optics.w || !guide_smooth_glass(next) || next.parameters.w>=0.0 || dot(normal,ray.direction)>=-0.000001 {guide_bypass(base,11u);return;}
let exit=guide_interface(next,hit.bary,ray.direction,normal,t.parameters.z,1.0);
if exit.fresnel.z<0.0 {guide_bypass(base,12u);return;}
if dot(exit.transmitted,normal)>=-0.000001 {guide_bypass(base,10u);return;}
ray=guide_launch(next,hit,normal,exit.transmitted);
stage=2u;
bounce=2u;
continue;
}
if next.base.w>0.0 {guide_bypass(base,13u);return;}
if next.optics.w<1.0 || next.optics.w>511.0 {guide_bypass(base,15u);return;}
let endpoint_position=surface_point(next,hit.bary);
let endpoint_look=guide_look(next,hit,ray.direction,endpoint_position,travel,bounce);
if guide_sharp(next,endpoint_look) {guide_bypass(base,14u);return;}
if stage==0u {
reflected_body=u32(next.optics.w);
if has_transmission {
reflected_look=endpoint_look;
reflected_position=endpoint_position;
ray=guide_launch(t,primary,geometric,transmitted);
stage=forward_stage;
travel=primary_travel;
continue;
}
} else {transmitted_body=u32(next.optics.w);}
let identity=0xa0000000u|(u32(t.optics.w)<<18u)|(reflected_body<<9u)|transmitted_body;
guide_write(base,bitcast<f32>(identity),endpoint_look,endpoint_position,primary_footprint);
if reflected_body!=0u && transmitted_body!=0u {
guide_write(base+3u,f32(reflected_body),reflected_look,reflected_position,primary_footprint);
}
return;
}
}
+700 -29
View File
@@ -9,7 +9,7 @@ use solidcraft_render::{
};
use std::sync::Arc;
type Guide = [[f64; 4]; 3];
type Guide = [[f64; 4]; 6];
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a.into_iter().zip(b).map(|(x, y)| x * y).sum()
}
@@ -23,18 +23,33 @@ fn xyz(a: [f64; 4]) -> [f64; 3] {
// Independent f64 gather oracle: all inputs are averaged linear values, so the
// GPU's accumulated-sample scaling is absent from this numerical implementation.
fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) -> Vec<[f64; 4]> {
reference_settings(width, input, guides, step, 1.0, true, true)
}
fn reference_settings(
width: usize,
input: &[[f64; 4]],
guides: &[Guide],
step: usize,
strength: f64,
fireflies: bool,
optical_filter: bool,
) -> Vec<[f64; 4]> {
let height = input.len() / width;
let kernel = [1.0, 4.0, 6.0, 4.0, 1.0];
input
.iter()
.enumerate()
.map(|(i, &center)| {
let [a, n, p] = guides[i];
if a[3] == 0.0 || center[3] < 0.999 {
let [a, n, p, ar, nr, pr] = guides[i];
let optical = a[3] < 0.0;
if a[3] == 0.0 || center[3] < 0.999 || (optical && (!optical_filter || step > 2)) {
return center;
}
let mut result = [0.0; 4];
let mut total = 0.0;
let maximum = |v: [f64; 3]| v.into_iter().fold(0.0, f64::max);
let center_peak = maximum(xyz(center));
let mut support = Vec::new();
for (ky, &wy) in kernel.iter().enumerate() {
for (kx, &wx) in kernel.iter().enumerate() {
let x = (i % width) as isize + (kx as isize - 2) * step as isize;
@@ -43,7 +58,7 @@ fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) ->
continue;
}
let j = y as usize * width + x as usize;
let [b, m, q] = guides[j];
let [b, m, q, br, mr, qr] = guides[j];
let value = input[j];
if a[3] != b[3] || (value[3] - center[3]).abs() > 0.01 {
continue;
@@ -53,13 +68,29 @@ fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) ->
let plane = dot(position, xyz(n)).abs() + dot(position, xyz(m)).abs();
let scale = (0.05 * p[3].max(q[3]) * step as f64).max(1e-6);
let depth = (n[3] - m[3]).abs() / (8.0 * p[3].max(q[3]) * step as f64).max(1e-6);
let color = diff(center, value);
let maximum = |v: [f64; 3]| v.into_iter().fold(0.0, f64::max);
let color_scale = 0.25 + 0.5 * maximum(xyz(center)).max(maximum(xyz(value)));
let weight = wx
* wy
* (-dot(albedo, albedo) / 0.04 - plane / scale - depth - maximum(color.map(f64::abs)) / color_scale).exp()
* dot(xyz(n), xyz(m)).max(0.0).powi(32);
let mut guide_weight = (-dot(albedo, albedo) / 0.04 - plane / scale - depth).exp() * dot(xyz(n), xyz(m)).max(0.0).powi(32);
if ar[3] != br[3] {
continue;
}
if ar[3] != 0.0 {
let da = diff(ar, br);
let dp = diff(qr, pr);
let footprint = pr[3].max(qr[3]) * step as f64;
let plane = dot(dp, xyz(nr)).abs() + dot(dp, xyz(mr)).abs();
let depth = (nr[3] - mr[3]).abs() / (8.0 * footprint).max(1e-6);
guide_weight *=
(-dot(da, da) / 0.04 - plane / (0.05 * footprint).max(1e-6) - depth).exp() * dot(xyz(nr), xyz(mr)).max(0.0).powi(32);
}
let tap_peak = maximum(xyz(value));
if !optical && step == 1 && (1..=3).contains(&kx) && (1..=3).contains(&ky) && i != j && guide_weight >= 0.5 {
support.push(tap_peak);
}
let color = maximum(diff(center, value).map(f64::abs));
let high = center_peak.max(tap_peak);
let bound = 16.0 * (0.25 + center_peak.min(tap_peak));
let contrast = if !fireflies || high <= bound { 1.0 } else { (bound / high).powi(2) };
let color_weight = if optical { 4.0 } else { 1.0 };
let weight = wx * wy * guide_weight * (-color_weight * color / (0.25 + 0.5 * high)).exp() * contrast;
for k in 0..3 {
result[k] += value[k] * weight;
}
@@ -69,13 +100,26 @@ fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) ->
for v in result.iter_mut().take(3) {
*v /= total;
}
if fireflies && support.len() >= 5 {
let support_peak = support.into_iter().fold(0.0, f64::max);
let cap = 2.0 * support_peak + 0.25;
let peak = maximum(xyz(result));
if center_peak > 16.0 * (0.25 + support_peak) && peak > cap {
for v in &mut result[..3] {
*v *= cap / peak;
}
}
}
for (value, original) in result.iter_mut().take(3).zip(center) {
*value = original * (1.0 - strength) + *value * strength;
}
result[3] = center[3];
result
})
.collect()
}
fn guide(x: usize, y: usize) -> Guide {
[[0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0, 4.0], [x as f64, y as f64, 0.0, 1.0]]
[[0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0, 4.0], [x as f64, y as f64, 0.0, 1.0], [0.0; 4], [0.0; 4], [0.0; 4]]
}
fn fixture(size: usize) -> (Vec<[f64; 4]>, Vec<Guide>) {
let mut pixels = Vec::new();
@@ -219,15 +263,15 @@ fn denoise_admission_counts_guides_and_ping_pong_only_when_enabled() {
let request = request(32);
let plain = super::super::admission(&scene, &request, JobOptions::default()).unwrap();
let filtered = super::super::admission(&scene, &request, JobOptions { denoise: true, ..Default::default() }).unwrap();
assert_eq!(filtered.pixel_scratch_bytes - plain.pixel_scratch_bytes, 32 * 32 * 80);
assert_eq!(filtered.estimated_total_bytes - plain.estimated_total_bytes, 32 * 32 * 80);
assert_eq!(filtered.pixel_scratch_bytes - plain.pixel_scratch_bytes, 32 * 32 * 128);
assert_eq!(filtered.estimated_total_bytes - plain.estimated_total_bytes, 32 * 32 * 128);
let large = ImageRequest { width: 2000, height: 2000, ..request.clone() };
assert!(super::super::admission(&scene, &large, JobOptions::default()).is_ok());
// The production envelope deliberately admits this former 512 MiB refusal.
// Still charge the guides/ping-pong in full, and reject outside the new edge.
let large_plain = super::super::admission(&scene, &large, JobOptions::default()).unwrap();
let large_filtered = super::super::admission(&scene, &large, JobOptions { denoise: true, ..Default::default() }).unwrap();
assert_eq!(large_filtered.pixel_scratch_bytes - large_plain.pixel_scratch_bytes, 2000 * 2000 * 80);
assert_eq!(large_filtered.pixel_scratch_bytes - large_plain.pixel_scratch_bytes, 2000 * 2000 * 128);
assert!(large_filtered.estimated_total_bytes > 512 * 1024 * 1024);
assert!(large_filtered.estimated_total_bytes <= solidcraft_render::job::GPU_PATH_LIMITS.max_scratch_bytes);
let excessive = ImageRequest { width: (solidcraft_render::job::GPU_PATH_LIMITS.max_dimension + 1) as u32, ..request.clone() };
@@ -238,7 +282,7 @@ fn denoise_admission_counts_guides_and_ping_pong_only_when_enabled() {
assert!(serde_json::from_str::<JobOptions>(r#"{"denoise":"true"}"#).is_err());
}
#[test]
fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
fn gpu_atrous_matches_f64_with_firefly_line_and_material_boundary() {
let Some(context) = context() else {
return;
};
@@ -249,9 +293,27 @@ fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
input[y * 13 + x][0] += 0.8;
}
}
for y in 0..13 {
input[y * 13 + 10] = [256.0, 64.0, 16.0, 1.0];
}
let isolated = 6 * 13 + 3;
let material_boundary = 2 * 13 + 6;
input[isolated] = [512.0, 128.0, 32.0, 1.0];
input[material_boundary] = input[isolated];
// Same body but incompatible albedo. The neighbouring floor cannot
// supply support for capping a highlight on this material patch.
guides[material_boundary][0] = [1.0; 4];
let optical_glint = 2 * 13 + 8;
for y in 1..=3 {
for x in 7..=9 {
guides[y * 13 + x][0][3] = f64::from(f32::from_bits(0xa004_0400));
}
}
input[optical_glint] = [1024.0, 256.0, 64.0, 1.0];
guides[0][0][3] = 0.0;
input[1][3] = 0.5;
let bytes: Vec<u8> = input.iter().flatten().flat_map(|x| (*x as f32).to_le_bytes()).collect();
let samples = 8;
let bytes: Vec<u8> = input.iter().flatten().flat_map(|x| (*x as f32 * samples as f32).to_le_bytes()).collect();
let device = &context.device;
let make = |label, bytes: &[u8]| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -262,25 +324,193 @@ fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
};
let raw = make("denoise fixture raw", &bytes);
let dummy = make("denoise unused geometry", &[0; 64]);
let mut task = Task::new(context.clone(), plane(), request(13), JobOptions::default(), CancelToken::default()).unwrap();
task.sample = 1;
let options = JobOptions {
denoise_settings: solidcraft_render::job::DenoiseSettings { firefly_suppression: true, ..Default::default() },
..Default::default()
};
let mut task = Task::new(context.clone(), plane(), request(13), options, CancelToken::default()).unwrap();
task.sample = samples;
let filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64, false).unwrap();
let guide_bytes: Vec<u8> = guides.iter().flatten().flatten().flat_map(|x| (*x as f32).to_le_bytes()).collect();
context.queue.write_buffer(&filter._guides, 0, &guide_bytes);
let before = task.read_linear_buffer(&raw).unwrap();
filter.run_filter(&task, false).unwrap();
assert_eq!(before, task.read_linear_buffer(&raw).unwrap());
let actual = task.read_linear_buffer(filter.output()).unwrap();
let mut expected = input;
for step in [1, 2, 4] {
expected = reference(13, &expected, &guides, step);
}
let actual = task.read_linear_buffer(filter.output(3)).unwrap();
let first_pass = reference(13, &input, &guides, 1);
assert!(first_pass[isolated][0] < 1.5);
let expected = reference(13, &reference(13, &first_pass, &guides, 2), &guides, 4);
for (a, b) in actual.iter().zip(&expected) {
for (x, y) in a.iter().zip(b) {
assert!((f64::from(*x) - *y).abs() < 2e-5, "{a:?} vs {b:?}");
assert_eq!(a[3].to_bits(), (b[3] as f32).to_bits());
for (x, y) in a[..3].iter().zip(&b[..3]) {
assert!((f64::from(*x) - *y).abs() < 2e-5 * y.abs().max(1.0), "{a:?} vs {b:?}");
}
}
assert!(actual[isolated][0] < 0.7, "the isolated firefly must come down to its neighbourhood");
assert!(actual[6 * 13 + 10][0] > 254.0, "a supported bright line must survive");
assert!(actual[material_boundary][0] > 511.0, "incompatible guide support must preserve the material highlight");
assert!(actual[optical_glint][0] > 1023.0, "a single optical glint with matching guides must survive");
}
#[test]
fn gpu_optical_filter_preserves_edges_on_the_same_reflected_body() {
let Some(context) = context() else {
return;
};
let device = &context.device;
let input: Vec<[f32; 4]> = (0..25).map(|i| if i == 12 { [0.8, 0.8, 0.8, 1.0] } else { [0.4, 0.4, 0.4, 1.0] }).collect();
let bytes: Vec<_> = input.iter().flatten().flat_map(|x| x.to_le_bytes()).collect();
let raw = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mixed glass edge radiance"),
contents: &bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
});
let dummy = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("unused optical edge geometry"),
contents: &[0; 64],
usage: wgpu::BufferUsages::STORAGE,
});
let mut task = Task::new(context.clone(), plane(), request(5), JobOptions::default(), CancelToken::default()).unwrap();
task.sample = 1;
let filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64, false).unwrap();
for edge in 0..4 {
let mut g = guide(0, 0);
g[0][3] = f64::from(f32::from_bits(0xa004_0403));
g[3] = [0.5, 0.5, 0.5, 2.0];
g[4] = [0.0, 0.0, 1.0, 4.0];
g[5] = [0.0, 0.0, 0.0, 1.0];
let mut guides = vec![g; 25];
match edge {
0 => guides[12][3] = [1.0, 1.0, 1.0, 2.0],
1 => guides[12][4] = [1.0, 0.0, 0.0, 4.0],
2 => guides[12][4][3] = 1000.0,
_ => guides[12][5][2] = 10.0,
}
let packed: Vec<_> = guides.iter().flatten().flatten().flat_map(|x| (*x as f32).to_le_bytes()).collect();
context.queue.write_buffer(&filter._guides, 0, &packed);
filter.run_filter(&task, false).unwrap();
let actual = task.read_linear_buffer(filter.output(3)).unwrap();
assert!(actual[12][0] > 0.799, "reflected edge {edge}: {:?}", actual[12]);
assert_eq!(task.read_linear_buffer(&raw).unwrap(), input);
}
}
fn optical_plane(z: f64, albedo: Vec3) -> ProductMesh {
let mut body = plane().meshes.remove(0);
for point in &mut Arc::make_mut(&mut body.mesh).positions {
point.x *= 3.0;
point.y *= 3.0;
point.z = z;
}
body.surface = Some(OpaqueSurface::new(albedo, 0.0, 0.7, 1.0).unwrap());
body
}
fn optical_box() -> Mesh {
let faces = [
([[-3., -3., 0.5], [3., -3., 0.5], [3., 3., 0.5], [-3., 3., 0.5]], Vec3::Z),
([[-3., 3., -0.5], [3., 3., -0.5], [3., -3., -0.5], [-3., -3., -0.5]], -Vec3::Z),
([[3., -3., -0.5], [3., 3., -0.5], [3., 3., 0.5], [3., -3., 0.5]], Vec3::X),
([[-3., 3., -0.5], [-3., -3., -0.5], [-3., -3., 0.5], [-3., 3., 0.5]], -Vec3::X),
([[3., 3., -0.5], [-3., 3., -0.5], [-3., 3., 0.5], [3., 3., 0.5]], Vec3::Y),
([[-3., -3., -0.5], [3., -3., -0.5], [3., -3., 0.5], [-3., -3., 0.5]], -Vec3::Y),
];
let mut mesh = Mesh::default();
for (points, normal) in faces {
let start = mesh.positions.len() as u32;
mesh.positions.extend(points.map(|[x, y, z]| Vec3::new(x, y, z)));
mesh.normals.extend([normal; 4]);
mesh.triangles.extend([[start, start + 1, start + 2], [start, start + 2, start + 3]]);
mesh.tri_face.extend([0; 2]);
}
mesh
}
#[test]
fn gpu_denoise_optical_guides_track_branches_and_certified_snell_endpoints() {
use solidcraft_render::{
glass_model::{GlassModel, ThinFilm},
job::{AreaLight, AreaLights, StudioLighting, ThinLens},
transmission::Transmission,
};
let Some(context) = context() else { return };
let mut image = request(1);
let incident = 0.4_f64;
image.camera.pitch = std::f64::consts::FRAC_PI_2 - incident;
image.camera.yaw = 0.0;
image.camera.fov = 0.4;
let reflected = optical_plane(6.0, Vec3::new(0.2, 0.4, 0.6));
let transmitted = optical_plane(-3.0, Vec3::new(0.7, 0.3, 0.2));
let base = optical_plane(0.0, Vec3::ZERO);
let options = JobOptions { samples: 1, max_depth: 4, denoise: true, ..Default::default() };
let probe = |meshes: Vec<ProductMesh>, options| {
let bounds = meshes.iter().fold(solidcraft_geom::Aabb3::EMPTY, |bounds, body| bounds.union(&body.mesh.bounds()));
let task = Task::new(context.clone(), ProductScene { meshes, bounds }, image.clone(), options, CancelToken::default()).unwrap();
let filter = task.denoiser.as_ref().unwrap();
filter.generate_guides(&task).unwrap();
assert!(read_guides(&context, &task.failures).iter().all(|word| *word == [0.0; 4]), "guide tracing cannot write production failures");
read_guides(&context, &filter._guides)
};
let tag = |reflection: u32, transmission: u32| 0xa0000000_u32 | (1_u32 << 18) | (reflection << 9) | transmission;
let bypass = |reason: DenoiseGuideClass| {
let mut guides = vec![[0.0; 4]; 6];
guides[0][0] = reason.code() as f32;
guides
};
let mut mirror = base.clone();
mirror.surface = Some(OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, 0.045, 1.5).unwrap());
assert_eq!(probe(vec![mirror.clone()], options), bypass(DenoiseGuideClass::Unavailable));
assert_eq!(probe(Vec::new(), options), bypass(DenoiseGuideClass::Background));
let direct_emitter = AreaLight { center: [0.0, 0.0, 2.0], u: [2.0, 0.0, 0.0], v: [0.0, 2.0, 0.0], radiance: [1.0; 3] };
let direct_lighting = StudioLighting { area_lights: AreaLights::new(&[direct_emitter]).unwrap(), ..Default::default() };
assert_eq!(probe(Vec::new(), JobOptions { lighting: Some(direct_lighting), ..options }), bypass(DenoiseGuideClass::Emitter));
let guide = probe(vec![mirror.clone(), reflected.clone()], options);
assert_eq!(guide[0], [0.2, 0.4, 0.6, f32::from_bits(tag(2, 0))]);
assert_eq!(&guide[1][..3], &[0.0, 0.0, -1.0]);
assert!(guide[1][3] < 0.0, "the reflected endpoint is behind the camera");
assert!((guide[2][1] - (6.0 * incident.tan()) as f32).abs() < 2e-5);
assert!(guide[2][3] >= 1.6, "the endpoint behind the camera retains the primary footprint");
let lens = ThinLens { aperture_radius_mm: 0.1, focus_distance_mm: 4.0 };
assert_eq!(probe(vec![mirror, reflected.clone()], JobOptions { lens: Some(lens), ..options }), bypass(DenoiseGuideClass::DepthOfField));
let mut thin = base.clone();
thin.surface = Some(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.7, 1.5).unwrap());
thin.transmission = Some(Transmission::new(Vec3::ZERO, Some(0.0)).unwrap());
let thin_scene = vec![thin.clone(), reflected.clone(), transmitted.clone()];
let guide = probe(thin_scene.clone(), options);
assert_eq!(guide[0], [0.7, 0.3, 0.2, f32::from_bits(tag(2, 3))]);
assert_eq!(guide[3], [0.2, 0.4, 0.6, 2.0]);
assert_eq!(&guide[4][..3], &[0.0, 0.0, -1.0]);
assert_eq!(&guide[1][..3], &[0.0, 0.0, 1.0]);
assert!((guide[2][1] - (3.0 * incident.tan()) as f32).abs() < 2e-5);
assert_eq!(
probe(vec![thin.clone(), transmitted.clone()], options),
bypass(DenoiseGuideClass::Unavailable),
"both weighted branches need an opaque endpoint"
);
let emitter = AreaLight { center: [0.0, 2.0, 5.0], u: [2.0, 0.0, 0.0], v: [0.0, 2.0, 0.0], radiance: [1.0; 3] };
let blocked = StudioLighting { area_lights: AreaLights::new(&[emitter]).unwrap(), ..Default::default() };
assert_eq!(probe(thin_scene, JobOptions { lighting: Some(blocked), ..options }), bypass(DenoiseGuideClass::Unavailable));
let film = GlassModel { roughness: 0.0, dispersion: None, thin_film: Some(ThinFilm { ior: 1.35, thickness_nm: 200.0 }) };
let mut modeled = thin.clone();
modeled.transmission = Some(modeled.transmission.unwrap().with_model(film).unwrap());
assert_eq!(probe(vec![modeled, reflected.clone(), transmitted.clone()], options), bypass(DenoiseGuideClass::UnsupportedMaterial));
let mut matched = thin.clone();
matched.surface = base.surface;
let guide = probe(vec![matched, reflected.clone(), transmitted.clone()], options);
assert_eq!(guide[0][3].to_bits(), tag(0, 3));
let mut closed = thin;
closed.mesh = Arc::new(optical_box());
closed.transmission = Some(Transmission::new(Vec3::ZERO, None).unwrap());
let closed_scene = vec![closed, reflected, transmitted];
let guide = probe(closed_scene.clone(), JobOptions { nested_media: true, ..options });
assert_eq!(guide[0], [0.7, 0.3, 0.2, f32::from_bits(tag(2, 3))]);
assert_eq!(guide[3], [0.2, 0.4, 0.6, 2.0]);
assert_eq!(&guide[4][..3], &[0.0, 0.0, -1.0]);
let internal = (incident.sin() / 1.5).asin();
let snell_y = 2.0 * incident.tan() + internal.tan();
assert!((f64::from(guide[2][1]) - snell_y).abs() < 2e-5, "Snell displacement {guide:?} vs {snell_y}");
assert_eq!(probe(closed_scene, JobOptions { max_depth: 2, ..options }), bypass(DenoiseGuideClass::PathDepth));
}
#[test]
fn gpu_denoise_default_false_is_identical_and_true_preserves_raw() {
let Some(context) = context() else {
@@ -300,13 +530,16 @@ fn gpu_denoise_default_false_is_identical_and_true_preserves_raw() {
}
let raw = task.read_linear().unwrap();
if let Some(filter) = &task.denoiser {
use std::sync::atomic::Ordering;
assert!(!filter.guides_ready.load(Ordering::Relaxed));
filter.run(&task).unwrap();
assert!(filter.guides_ready.load(Ordering::Relaxed));
assert_eq!(raw, task.read_linear().unwrap());
let filtered = task.read_linear_buffer(filter.output()).unwrap();
let filtered = task.read_linear_buffer(filter.output(3)).unwrap();
assert!(filtered.iter().all(|p| p.iter().all(|v| v.is_finite())));
let guides = read_guides(&context, &filter._guides);
assert_eq!(guides[0], [0.0; 4]);
let center = (4 * 8 + 4) * 3;
assert_eq!(guides[0], [DenoiseGuideClass::Background.code() as f32, 0.0, 0.0, 0.0]);
let center = (4 * 8 + 4) * 6;
assert_eq!(guides[center], [0.5, 0.5, 0.5, 1.0]);
assert_eq!(guides[center + 1], [0.0, 0.0, 1.0, 16.0]);
assert!((guides[center + 2][0] - 0.2).abs() < 1e-6);
@@ -341,10 +574,448 @@ fn gpu_denoise_preflight_reports_limits_without_changing_document() {
assert_eq!(reply["options"]["denoise"], true);
assert_eq!(reply["denoising"]["passes"], 3);
assert_eq!(reply["denoising"]["raw_linear_accumulator_preserved"], true);
assert_eq!(reply["denoising"]["settings"]["optical_filter"], true);
params["denoise_settings"] = serde_json::json!({"optical_filter":false});
let opaque_only = session.execute("render.preflight", &params).unwrap();
assert_eq!(opaque_only["options"]["denoise_settings"]["optical_filter"], false);
assert_eq!(opaque_only["denoising"]["optical_policy"], "legacy opaque-only filtering; mirror and glass radiance bypass display filtering");
assert_eq!(opaque_only["denoising"]["settings"]["firefly_suppression"], false);
assert_eq!(before, session.doc.to_json());
params.as_object_mut().unwrap().remove("denoise_settings");
params["progressive"] = serde_json::json!(false);
assert!(session.execute("render.preflight", &params).is_err());
params["denoise"] = serde_json::json!(false);
let reply = session.execute("render.preflight", &params).unwrap();
assert!(reply.get("denoising").is_none());
}
#[test]
fn gpu_denoise_controls_match_f64_keep_alpha_and_leave_raw_unchanged() {
use solidcraft_render::job::DenoiseSettings;
let Some(context) = context() else { return };
let (mut input, mut guides) = fixture(13);
input[6 * 13 + 6] = [512.0, 128.0, 32.0, 1.0];
input[0][3] = 0.5;
for (pixel, guide) in guides.iter_mut().enumerate() {
let x = pixel % 13;
if x >= 7 {
// Distinct mirror and mixed-glass regions beside an ordinary surface.
let identity = if x < 10 { 0xa000_0000 | (2 << 18) | (1 << 9) } else { 0xa000_0000 | (3 << 18) | (1 << 9) | 4 };
guide[0][3] = f64::from(f32::from_bits(identity));
if x >= 10 {
guide[3] = [0.5, 0.5, 0.5, 1.0];
guide[4] = [0.0, 0.0, 1.0, 4.0];
guide[5] = [x as f64, (pixel / 13) as f64, 0.0, 1.0];
}
}
}
let samples = 4;
let bytes: Vec<_> = input.iter().flatten().flat_map(|value| (*value as f32 * samples as f32).to_le_bytes()).collect();
let raw = context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("denoise control radiance"),
contents: &bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
});
let dummy = context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("unused control geometry"),
contents: &[0; 64],
usage: wgpu::BufferUsages::STORAGE,
});
let mut task = Task::new(context.clone(), plane(), request(13), JobOptions::default(), CancelToken::default()).unwrap();
task.sample = samples;
let filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64, false).unwrap();
let packed: Vec<_> = guides.iter().flatten().flatten().flat_map(|value| (*value as f32).to_le_bytes()).collect();
context.queue.write_buffer(&filter._guides, 0, &packed);
let raw_before = task.read_linear_buffer(&raw).unwrap();
for (passes, strength, firefly_suppression) in [(1, 0.0, true), (1, 1.0, false), (1, 1.0, true), (2, 0.4, true), (3, 0.6, false)] {
let mut enabled = Vec::new();
for optical_filter in [true, false] {
task.options.denoise_settings = DenoiseSettings { passes, strength, firefly_suppression, optical_filter };
filter.run_filter(&task, false).unwrap();
let actual = task.read_linear_buffer(filter.output(passes)).unwrap();
let mut expected = input.clone();
for pass in 0..passes {
expected = reference_settings(13, &expected, &guides, 1 << pass, strength, firefly_suppression, optical_filter);
}
for (pixel, (actual, expected)) in actual.iter().zip(&expected).enumerate() {
assert_eq!(actual[3].to_bits(), (expected[3] as f32).to_bits());
for (a, e) in actual[..3].iter().zip(&expected[..3]) {
assert!(
(f64::from(*a) - *e).abs() < 3e-5 * e.abs().max(1.0),
"passes={passes} strength={strength} optical_filter={optical_filter}: {actual:?} vs {expected:?}"
);
}
if !optical_filter {
let unchanged = if guides[pixel][0][3] < 0.0 { raw_before[pixel] } else { enabled[pixel] };
assert_eq!(actual.map(f32::to_bits), unchanged.map(f32::to_bits));
}
}
if strength == 0.0 {
assert_eq!(actual, raw_before);
} else if optical_filter {
assert!(actual.iter().zip(&raw_before).enumerate().any(|(pixel, (filtered, raw))| guides[pixel][0][3] < 0.0 && filtered != raw));
}
assert_eq!(task.read_linear_buffer(&raw).unwrap(), raw_before);
if optical_filter {
enabled = actual;
}
}
}
}
#[test]
fn gpu_supersampling_admission_charges_internal_pixels_and_final_mask() {
let scene = plane();
let output = request(8);
let trace = request(16);
let native = super::super::admission(&scene, &trace, JobOptions::default()).unwrap();
let options = JobOptions { supersampling: 2, ..Default::default() };
let sampled = super::super::admission(&scene, &output, options).unwrap();
assert_eq!(sampled.pixel_scratch_bytes, native.pixel_scratch_bytes + 8 * 8 * 16);
assert_eq!(sampled.encoded_limit, 8 * 8 * 8 + 65_536);
let diagnostic = super::super::admission(&scene, &output, JobOptions { denoise_diagnostics: true, ..options }).unwrap();
assert_eq!(diagnostic.pixel_scratch_bytes - sampled.pixel_scratch_bytes, 16 * 16 * 128 + 8 * 8 * 12 + 65_536);
assert!(
super::super::admission(
&scene,
&ImageRequest { width: 7680, height: 4320, ..output.clone() },
JobOptions { samples: 128, ..Default::default() }
)
.is_ok()
);
for supersampling in [0, 3, u32::MAX] {
assert!(super::super::admission(&scene, &output, JobOptions { supersampling, ..Default::default() }).is_err());
}
assert!(super::super::admission(&scene, &request(8192), JobOptions::default()).is_err(), "8K output is also bounded by total pixels");
}
#[test]
fn gpu_supersampling_raw_master_uses_linear_rgba_and_selected_output_size() {
let Some(context) = context() else { return };
let options = JobOptions { samples: 2, supersampling: 2, linear_exr: true, ..Default::default() };
let mut task = Task::new(context.clone(), plane(), request(4), options, CancelToken::default()).unwrap();
assert_eq!((task.request.width, task.request.height), (8, 8));
while task.sample < options.samples {
task.complete_sample().unwrap();
}
let input = task.read_linear().unwrap();
let mut expected = Vec::new();
for y in 0..4 {
for x in 0..4 {
let positions = [(2 * y) * 8 + 2 * x, (2 * y) * 8 + 2 * x + 1, (2 * y + 1) * 8 + 2 * x, (2 * y + 1) * 8 + 2 * x + 1];
expected
.push(std::array::from_fn(|channel| (positions.into_iter().map(|index| f64::from(input[index][channel])).sum::<f64>() / 4.0) as f32));
}
}
let mut expected_exr = solidcraft_render::job::linear_exr::ExrTask::new(4, 4, expected, CancelToken::default()).unwrap();
while !expected_exr.step(4).unwrap() {}
let (expected_exr, _) = expected_exr.finish().unwrap();
let mut png = None;
for _ in 0..100 {
if let TaskPoll::Complete(bytes) = task.step(4).unwrap() {
png = Some(bytes);
break;
}
}
let png = image::load_from_memory(&png.unwrap()).unwrap().to_rgba8();
assert_eq!(png.dimensions(), (4, 4));
assert_eq!(task.take_linear_exr().unwrap(), expected_exr);
assert_eq!(task.read_linear().unwrap(), input, "resolution never mutates the transport accumulator");
}
#[test]
fn gpu_denoise_diagnostics_preserve_beauty_and_export_a_categorical_final_size_mask() {
let Some(context) = context() else { return };
let mut beauty = Vec::new();
let mut masters = Vec::new();
for enabled in [false, true] {
let options = JobOptions { samples: 2, supersampling: 2, linear_exr: true, denoise_diagnostics: enabled, ..Default::default() };
let mut task = Task::new(context.clone(), plane(), request(8), options, CancelToken::default()).unwrap();
for _ in 0..200 {
if let TaskPoll::Complete(png) = task.step(4).unwrap() {
beauty.push(png);
break;
}
}
masters.push(task.take_linear_exr().unwrap());
if enabled {
let report = task.denoise_diagnostics().unwrap();
assert_eq!((report.width, report.height), (16, 16));
assert_eq!(report.counts.iter().sum::<u64>(), 256);
assert!(report.pixels(DenoiseGuideClass::Surface) > 0);
assert!(report.pixels(DenoiseGuideClass::Background) > 0);
let mask = image::load_from_memory(&task.take_denoise_mask().unwrap()).unwrap().to_rgba8();
assert_eq!(mask.dimensions(), (8, 8));
assert!(mask.pixels().all(|pixel| DenoiseGuideClass::ALL.iter().any(|class| class.color() == pixel.0)));
assert!(task.take_denoise_mask().is_none());
} else {
assert!(task.denoise_diagnostics().is_none());
assert!(task.take_denoise_mask().is_none());
}
}
assert_eq!(beauty.len(), 2);
assert_eq!(beauty[0], beauty[1]);
assert_eq!(masters[0], masters[1]);
}
#[test]
fn gpu_filter_windows_keep_three_pass_halos_firefly_support_and_actual_frame_edges_exact() {
let Some(context) = context() else { return };
let (mut input, mut guides) = fixture(40);
for row in [8, 16, 24, 32] {
input[row * 40 + 9] = [512.0, 128.0, 32.0, 1.0];
}
for y in 0..40 {
input[y * 40 + 27] = [256.0, 64.0, 16.0, 1.0];
for x in 20..40 {
guides[y * 40 + x][0][3] = 2.0;
}
}
input[0] = [0.2, 0.3, 0.4, 0.5];
guides[39][0][3] = 0.0;
let bytes: Vec<u8> = input.iter().flatten().flat_map(|value| (*value as f32).to_le_bytes()).collect();
let guide_bytes: Vec<u8> = guides.iter().flatten().flatten().flat_map(|value| (*value as f32).to_le_bytes()).collect();
let make = |label, bytes: &[u8]| {
context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
})
};
let raw = make("full filter halo oracle", &bytes);
let dummy = make("unused filter geometry", &[0; 64]);
let options = JobOptions {
denoise: true,
denoise_settings: solidcraft_render::job::DenoiseSettings { passes: 3, strength: 1.0, firefly_suppression: true, optical_filter: true },
..Default::default()
};
let mut task = Task::new(context.clone(), plane(), request(40), JobOptions { denoise: false, ..options }, CancelToken::default()).unwrap();
task.sample = 1;
let full = Resources::new(&context, &task.uniform, [&dummy; 5], &raw, bytes.len() as u64, false).unwrap();
context.queue.write_buffer(&full._guides, 0, &guide_bytes);
full.run_filter(&task, false).unwrap();
let expected = task.read_linear_buffer(full.output(3)).unwrap();
let layout = super::super::image_pages::Layout::new(40, 40, options, 0, &context.device.limits()).unwrap().with_rows(8, 1).unwrap();
let size = u64::from(layout.max_window_pixels()) * 16;
let gathered = make("gathered filter halo fixture", &vec![0; size as usize]);
let paged = Resources::new(&context, &task.uniform, [&dummy; 5], &gathered, size, false).unwrap();
let mut actual = Vec::new();
for window in layout.windows() {
context.queue.write_buffer(&gathered, 0, &bytes[window.start as usize * 16..(window.start + window.pixels) as usize * 16]);
context.queue.write_buffer(&paged._guides, 0, &guide_bytes[window.start as usize * 96..(window.start + window.pixels) as usize * 96]);
paged.run_filter_window(&task, false, window).unwrap();
let count = u64::from(window.core_pixels) * 16;
task.readback_data(
count,
|encoder| {
encoder.copy_buffer_to_buffer(paged.output(3), u64::from(window.core_start - window.start) * 16, &task.readback, 0, count);
Ok(())
},
|data| {
actual.extend(
data.as_chunks::<16>()
.0
.iter()
.map(|pixel| std::array::from_fn::<_, 4, _>(|i| f32::from_le_bytes(pixel[i * 4..i * 4 + 4].try_into().unwrap()))),
);
Ok(())
},
)
.unwrap();
}
assert_eq!(actual, expected, "full dependency halos must preserve every filtered float, including real frame edges");
assert!(actual[8 * 40 + 9][0] < 1.0, "firefly support crosses the page boundary");
assert!(actual[16 * 40 + 27][0] > 254.0, "supported thin highlights cross the boundary unchanged");
assert_eq!(task.read_linear_buffer(&raw).unwrap(), input.iter().map(|pixel| pixel.map(|v| v as f32)).collect::<Vec<_>>());
}
/// Deliberately isolate storage/normalization from stochastic pilot allocation.
/// The existing adaptive tests cover the allocation oracle; this fixture gives
/// every row and page boundary both stopped and unfinished production pixels.
fn set_checkerboard_test_budgets(task: &Task) {
assert_eq!(task.sample, task.options.adaptive.unwrap().pilot_samples);
assert_eq!((task.offset, task.lane), (0, 0));
let context = &task.context;
let submission = super::super::checked_creation(&context.device, || {
let source = r#"
struct Params { page:vec4<u32> };
@group(0) @binding(0) var<uniform> p:Params;
@group(0) @binding(1) var<storage,read_write> film:array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id:vec3<u32>) {
let local=id.x;
if local>=p.page.y {return;}
let pixel=p.page.x+local;
let high=((pixel%p.page.z)+(pixel/p.page.z))%2u==1u;
film[2u*p.page.y+local]=vec4<f32>(f32(select(16u,32u,high)),0.0,0.0,0.0);
}
"#;
let module = context.device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("independent adaptive budget fixture"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("independent adaptive budget fixture"),
layout: None,
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let mut encoder = context.device.create_command_encoder(&Default::default());
for page in &task.pages {
let bytes: Vec<u8> = [page.start, page.pixels, task.request.width, 0].into_iter().flat_map(u32::to_le_bytes).collect();
let uniform = context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("independent adaptive page fixture"),
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
let entries = [
wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: page.accumulation.as_entire_binding() },
];
let bind = context.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("independent adaptive page fixture"),
layout: &layout,
entries: &entries,
});
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.dispatch_workgroups(page.pixels.div_ceil(64), 1, 1);
}
Ok(context.queue.submit([encoder.finish()]))
})
.unwrap();
context.wait(submission, TIMEOUT).unwrap();
}
fn read_test_production_budgets(task: &Task) -> Vec<u32> {
let mut budgets = Vec::new();
for page in &task.pages {
let bytes = u64::from(page.pixels) * 16;
task.readback_data(
bytes,
|encoder| {
encoder.copy_buffer_to_buffer(&page.accumulation, bytes * 2, &task.readback, 0, bytes);
Ok(())
},
|data| {
budgets.extend(data.as_chunks::<16>().0.iter().map(|pixel| f32::from_le_bytes(pixel[..4].try_into().unwrap()) as u32));
Ok(())
},
)
.unwrap();
}
budgets
}
#[test]
fn gpu_paged_film_matches_raw_finished_png_diagnostic_masks_and_complete_adaptive_previews() {
use solidcraft_render::job::{DenoiseSettings, adaptive::AdaptiveSampling, bloom::Bloom, finishing::Finishing};
let Some(context) = context() else { return };
let image = ImageRequest { width: 8, height: 21, ..request(8) };
for adaptive in [false, true] {
let options = JobOptions {
samples: if adaptive { 32 } else { 4 },
max_depth: 3,
seed: 91,
supersampling: 2,
denoise: true,
denoise_settings: DenoiseSettings { passes: 3, strength: 1.0, firefly_suppression: true, optical_filter: true },
denoise_diagnostics: true,
linear_exr: true,
adaptive: adaptive.then_some(AdaptiveSampling { pilot_samples: 16, min_samples: 16, ..Default::default() }),
finishing: Finishing { bloom: Some(Bloom { strength: 0.3, threshold: 0.1, radius: 2 }), ..Default::default() },
..Default::default()
};
let render = |rows| {
let mut task = Task::new_with_page_rows(context.clone(), plane(), image.clone(), options, CancelToken::default(), rows).unwrap();
if rows.is_some() {
assert_eq!(task.pages.len(), 6);
assert_eq!(task.pages.last().unwrap().pixels, 16 * 2, "the short final page must execute with its own plane stride");
}
task.preview_enabled = true;
let mut previews = Vec::new();
let mut budget_injected = false;
for _ in 0..50_000 {
let poll = task.step(4).unwrap();
if let Some(preview) = task.take_preview() {
assert_eq!(task.offset, 0, "previews own completed global passes");
assert_eq!(task.lane, 0);
if adaptive && preview.samples_per_pixel == 16 {
assert!(!budget_injected);
set_checkerboard_test_budgets(&task);
budget_injected = true;
}
previews.push((preview.samples_per_pixel, preview.png));
}
if let TaskPoll::Complete(png) = poll {
let raw = task.read_linear().unwrap();
let report = task.denoise_diagnostics().unwrap();
assert_eq!(report.counts.iter().sum::<u64>(), 16 * 42);
let budgets = if adaptive {
assert!(budget_injected);
let budgets = read_test_production_budgets(&task);
let expected: Vec<u32> = (0..16 * 42).map(|pixel| if ((pixel % 16) + (pixel / 16)) % 2 == 0 { 16 } else { 32 }).collect();
assert_eq!(budgets, expected, "both budgets cross every storage boundary, including the short last page");
let sampling = task.sampling_report().unwrap();
assert_eq!((sampling.minimum_production_samples, sampling.maximum_production_samples), (16, 32));
assert_eq!(sampling.pixels_at_cap, 16 * 42 / 2);
assert_eq!(sampling.production_samples, 16 * 42 * 24);
assert_eq!(sampling.primary_path_evaluations, 16 * 42 * 40);
budgets
} else {
Vec::new()
};
return (
png,
task.take_linear_exr().unwrap(),
report,
task.take_denoise_mask().unwrap(),
task.sampling_report(),
previews,
raw,
budgets,
);
}
}
panic!("bounded page-equivalence fixture did not complete");
};
let single = render(None);
let paged = render(Some(8));
assert_eq!(single.0, paged.0, "final PNG adaptive={adaptive}");
assert_eq!(single.1, paged.1, "raw EXR adaptive={adaptive}");
assert_eq!(single.2, paged.2, "exact category counts adaptive={adaptive}");
assert_eq!(single.3, paged.3, "X2 categorical mask adaptive={adaptive}");
assert_eq!(single.4, paged.4, "adaptive per-pixel budgets adaptive={adaptive}");
assert_eq!(single.5, paged.5, "completed checkpoint PNGs adaptive={adaptive}");
assert_eq!(single.6, paged.6, "raw float film adaptive={adaptive}");
assert_eq!(single.7, paged.7, "per-pixel stopped/unfinished production budgets adaptive={adaptive}");
assert_eq!(paged.5.iter().map(|preview| preview.0).collect::<Vec<_>>(), (1..options.preview_target().unwrap()).collect::<Vec<_>>());
println!(
"hardware page equivalence: adaptive={adaptive} pages=6 last_rows=2 halo=14 previews={} raw/png/mask/category/sample-report exact",
paged.5.len()
);
}
}
#[test]
fn gpu_paged_partial_pass_cancellation_publishes_no_preview_or_final_artifact() {
let Some(context) = context() else { return };
let token = CancelToken::default();
let image = ImageRequest { width: 8, height: 20, ..request(8) };
let options = JobOptions { samples: 2, supersampling: 2, denoise: true, denoise_diagnostics: true, linear_exr: true, ..Default::default() };
let mut task = Task::new_with_page_rows(context, plane(), image, options, token.clone(), Some(8)).unwrap();
task.preview_enabled = true;
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
assert!(task.offset > 0);
assert_eq!(task.sample, 0);
assert!(task.take_preview().is_none());
token.cancel();
assert_eq!(task.step(1).err().unwrap().code, solidcraft_render::job::ErrorCode::Cancelled);
assert!(task.take_preview().is_none());
assert!(task.take_linear_exr().is_none());
assert!(task.take_denoise_mask().is_none());
}
+77 -13
View File
@@ -9,6 +9,36 @@ use solidcraft_render::job::{
photographic::{Flare, Photographic},
};
// Validate each live receipt before comparing deterministic accounting. RAM
// snapshots and other retained leases can change between otherwise equal checks.
fn preflight_without_memory_observations(mut reply: serde_json::Value) -> serde_json::Value {
let total = reply["admission"]["estimated_total_bytes"].as_u64().unwrap();
let memory = reply["admission"]["host_memory"].as_object_mut().expect("native preflight reports its RAM admission");
let required = memory["required_bytes"].as_u64().unwrap();
let admitted = memory["admitted_bytes"].as_u64().unwrap();
let credited = memory["credited_bytes"].as_u64().unwrap();
let additional = memory["additional_bytes"].as_u64().unwrap();
let physical = memory["physical_debit_bytes"].as_u64().unwrap();
let available = memory["available_bytes"].as_u64().unwrap();
let capacity = memory["capacity_bytes"].as_u64().unwrap();
assert_eq!(required, total, "native debit includes the complete public admission");
assert_eq!(required.checked_sub(credited), Some(additional));
assert_eq!(credited, required, "native preflight reports its fully funded prepared capture");
assert_eq!(additional, 0, "the final capture self-check needs no new allocation");
assert!(additional <= available, "new native debit fits its available RAM: {memory:?}");
assert_eq!(available, capacity.saturating_sub(physical), "physical debit accounts for the snapshot allowance");
assert!(admitted.checked_add(required).unwrap() <= memory["software_limit_bytes"].as_u64().unwrap());
assert!(
matches!(memory["source"].as_str(), Some("system_available" | "system_and_cgroup" | "conservative_fallback")),
"native receipt identifies its capacity source"
);
// Keep required_bytes and every non-observation field in the exact comparison.
for field in ["admitted_bytes", "available_bytes", "capacity_bytes", "physical_debit_bytes", "source"] {
memory.remove(field).unwrap();
}
reply
}
#[test]
fn saved_targeted_aging_reaches_preflight_and_start_after_cold_reopen() {
let Some(executor) = executor() else { return };
@@ -48,7 +78,10 @@ fn saved_targeted_aging_reaches_preflight_and_start_after_cold_reopen() {
assert_eq!(runtime_target["body_name"], "Target panel");
assert_ne!(runtime_target["body_index"], original["options"]["aging"][0]["target"]["body_index"]);
let saved = session.doc.render.setups["Aging"].clone();
assert_eq!(checked, session.execute("render.preflight", &json!({"settings":saved})).unwrap());
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(session.execute("render.preflight", &json!({"settings":saved})).unwrap())
);
assert_eq!(host.admitted_bytes(), 0);
let started = session.execute("render.start", &json!({"setup":"Aging"})).unwrap();
@@ -294,7 +327,10 @@ fn saved_aging_identity_reaches_real_gpu_after_cold_reopen() {
assert_ne!(first["options"]["aging"][0]["target"]["body_index"], checked["options"]["aging"][0]["target"]["body_index"]);
assert_eq!(checked["options"]["aging"][0]["target"]["body_name"], "Target panel");
let saved = session.doc.render.setups["Aging"].clone();
assert_eq!(checked, session.execute("render.preflight", &json!({"settings":saved})).unwrap());
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(session.execute("render.preflight", &json!({"settings":saved})).unwrap())
);
let physical = session.execute("component.properties", &json!({})).unwrap();
let before = session.doc.clone();
let history = (session.revision, session.undo.len(), session.redo.len());
@@ -594,7 +630,12 @@ fn scratch_geometry_reference(@builtin(global_invocation_id) gid:vec3<u32>){
let carry = f32::from_le_bytes(parameters[carry_offset..carry_offset + 4].try_into().unwrap());
let expected = stack.gpu_layers([target.x, target.y, target.z]).unwrap()[0][11];
assert_eq!(carry.to_bits(), expected.to_bits());
assert_eq!(&parameters[parameters.len() - 4..], &[0; 4], "the determinant materialization lane remains positive zero");
// studio_weights.w precedes the sixteen-byte page metadata.
assert_eq!(
&parameters[parameters.len() - 20..parameters.len() - 16],
&[0; 4],
"the determinant materialization lane remains positive zero"
);
context.queue.write_buffer(&task.uniform, 0, &parameters);
let mut encoder = context.device.create_command_encoder(&Default::default());
{
@@ -823,7 +864,10 @@ fn saved_coat_gpu_capture_reopen_and_actual_render_match_independent_normal_inci
let mut plain = saved.clone();
plain.coats = Default::default();
let baseline_check = session.execute("render.preflight", &json!({"settings":plain})).unwrap();
assert_eq!(checked["admission"], baseline_check["admission"]);
assert_eq!(
preflight_without_memory_observations(checked.clone())["admission"],
preflight_without_memory_observations(baseline_check.clone())["admission"]
);
assert_eq!(checked["retained_options_bytes"], baseline_check["retained_options_bytes"]);
let mut zero = saved;
let row = zero.coats.iter().next().unwrap().clone();
@@ -876,33 +920,53 @@ fn saved_gpu_coat_survives_builtin_maps_aging_and_guided_filter_without_changing
let world = session.world_state().unwrap();
let session_camera = session.camera;
let history = (session.revision, session.model.recomputed, session.undo.len(), session.redo.len());
let complete = |session: &mut solidcraft_engine::Session, setup: solidcraft_engine::doc::render_settings::RenderSetup| {
let complete = |variant: &str, session: &mut solidcraft_engine::Session, setup: solidcraft_engine::doc::render_settings::RenderSetup| {
let submitted = std::time::Instant::now();
let reply = session.execute("render.start", &json!({"settings":setup})).unwrap();
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
let handle = session.render_job_handle(id).unwrap();
let status = handle.wait_timeout(Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{status:?}");
handle.frame().unwrap().unwrap()
// Cold Vulkan setup can compile mapped and denoising pipelines. Allow that
// preparation separately, then keep one fixed normal-render deadline.
let queue_deadline = submitted + Duration::from_secs(30);
let setup_deadline = submitted + Duration::from_secs(600);
let mut render_deadline = None;
let status = loop {
let status = handle.status().unwrap_or_else(|error| panic!("{variant}: render status failed: {error:?}"));
let now = std::time::Instant::now();
let deadline = match status.progress.phase {
Phase::Completed => break status,
Phase::Failed | Phase::Cancelled => panic!("{variant}: render ended: {status:?}"),
Phase::Queued => queue_deadline,
Phase::Preparing => setup_deadline,
_ => *render_deadline.get_or_insert(now + Duration::from_secs(60)),
};
assert!(now < deadline, "{variant}: render did not complete within its phase budget: {status:?}");
std::thread::sleep(Duration::from_millis(5));
};
handle
.frame()
.unwrap_or_else(|error| panic!("{variant}: completed frame lookup failed: {error:?}; {status:?}"))
.unwrap_or_else(|| panic!("{variant}: completed without a frame: {status:?}"))
};
let saved = session.doc.render.setups["Coat"].clone();
let actual = complete(&mut session, saved.clone());
let actual = complete("actual", &mut session, saved.clone());
let mut uncoated = saved.clone();
uncoated.coats = Default::default();
let plain = complete(&mut session, uncoated);
let plain = complete("plain", &mut session, uncoated);
assert_ne!(actual.linear_exr().unwrap(), plain.linear_exr().unwrap(), "maps and aging cannot erase the captured coat");
let mut unmapped = saved.clone();
unmapped.surfaces = Default::default();
let no_map = complete(&mut session, unmapped);
let no_map = complete("no_map", &mut session, unmapped);
assert_ne!(actual.linear_exr().unwrap(), no_map.linear_exr().unwrap(), "maps still modify the coated substrate");
let mut unaged = saved.clone();
let Some(solidcraft_engine::doc::render_settings::RenderJob::GpuPath { options, .. }) = &mut unaged.job else { panic!("GPU") };
options.aging = Default::default();
let no_age = complete(&mut session, unaged);
let no_age = complete("no_age", &mut session, unaged);
assert_ne!(actual.linear_exr().unwrap(), no_age.linear_exr().unwrap(), "aging still modifies the mapped coat/substrate");
let mut unfiltered = saved.clone();
let Some(solidcraft_engine::doc::render_settings::RenderJob::GpuPath { options, .. }) = &mut unfiltered.job else { panic!("GPU") };
options.denoise = false;
let raw = complete(&mut session, unfiltered);
let raw = complete("raw", &mut session, unfiltered);
assert_eq!(actual.linear_exr().unwrap(), raw.linear_exr().unwrap(), "denoising cannot enter coated raw transport");
assert_eq!(session.doc.render.setups["Coat"], saved);
assert!(Arc::ptr_eq(&document, &session.doc));
+3 -3
View File
@@ -85,7 +85,7 @@ fn glass_importance_normal(incident:vec3<f32>,outgoing:vec3<f32>,normal:vec3<f32
return correction;
}
// One uniformly retained hard-light vertex keeps native optical visibility at
// one call site with96 bytes of private payload. For K<=16 eligible camera
// one call site with96 bytes of private payload. For K<=64 eligible camera
// vertices, K*prefix[J]*direct[J] estimates their complete sum. Selection uses
// its own RNG, leaving transport draws unchanged. Null/depth exits retain the
// estimator; multi-vertex map-off paths acquire additional sampling variance.
@@ -97,7 +97,7 @@ fn glass_offer_light(lights:ptr<function,array<vec4<u32>,6>>,count:ptr<function,
let studio=u.environment.w>0.0;
if select(3u,u32(u.light_count.x)+u32(u.light_count.y),studio)==0u {return;}
let index=*count;
if index>=16u || index>=u.sampling.y {atomicOr(&failures[7],8u);return;}
if index>=64u || index>=u.sampling.y {atomicOr(&failures[7],8u);return;}
*count=index+1u;
if index>0u && random(selection_rng)>=1.0/f32(index+1u) {return;}
(*lights)[0]=vec4<u32>(bitcast<vec3<u32>>(point.hi),source_triangle);
@@ -112,7 +112,7 @@ fn glass_offer_light(lights:ptr<function,array<vec4<u32>,6>>,count:ptr<function,
// Film planes precede this scratch and retain their historical offsets. Integer
// metadata uses exact numeric f32 encoding rather than subnormal bit patterns.
fn glass_tile_address(pixel:u32)->u32 {
return u.image.x*u.image.y*select(1u,3u,u.adaptive_budget.x>0u)+(pixel-u.image.w)*8u;
return film_pixels()*select(1u,3u,u.adaptive_budget.x>0u)+(pixel-u.image.w)*8u;
}
fn glass_capture_light(lights:ptr<function,array<vec4<u32>,6>>,count:u32,wavelength:f32,pixel:u32) {
let address=glass_tile_address(pixel);
+156
View File
@@ -0,0 +1,156 @@
//! Row-aligned GPU storage pages and filter windows. Page boundaries change
//! resource residency only; camera/sample identities remain full-image indices.
use super::{FAILURE_BYTES, JobError, JobOptions, Result};
use solidcraft_render::job::{ErrorCode, MAX_TRACE_DIMENSION, MAX_TRACE_PIXELS};
// A working-set target, not an image-size limit: larger images use more pages.
// Actual adapter binding/allocation limits can make pages smaller.
pub(super) const TARGET_BUFFER_BYTES: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Copy, Debug)]
pub(super) struct Layout {
pub width: u32,
pub height: u32,
pub rows: u32,
pub halo: u32,
pub planes: u32,
pub optical_bytes: u64,
filter: bool,
adaptive: bool,
}
#[derive(Clone, Copy, Debug)]
pub(super) struct Window {
pub start: u32,
pub pixels: u32,
pub core_start: u32,
pub core_pixels: u32,
}
fn limit(message: &str) -> JobError {
JobError::new(ErrorCode::LimitExceeded, message)
}
impl Layout {
pub(super) fn new(width: u32, height: u32, options: JobOptions, optical_bytes: u64, limits: &wgpu::Limits) -> Result<Self> {
if width == 0
|| height == 0
|| width > MAX_TRACE_DIMENSION
|| height > MAX_TRACE_DIMENSION
|| u64::from(width) * u64::from(height) > MAX_TRACE_PIXELS
{
return Err(JobError::new(ErrorCode::InvalidRequest, "GPU image pages require a bounded nonempty trace grid"));
}
options.denoise_settings.validate()?;
let adaptive = options.adaptive.is_some();
let planes = if adaptive { 3 } else { 1 };
let filter = options.denoise || options.denoise_diagnostics;
let halo = if options.denoise { 2 * ((1 << options.denoise_settings.passes) - 1) } else { 0 };
let storage = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size).min(TARGET_BUFFER_BYTES);
let film_row = u64::from(width) * 16 * u64::from(planes);
let storage_rows = storage.checked_sub(optical_bytes).ok_or_else(|| limit("GPU storage cannot fit optical dispatch scratch"))? / film_row;
let readback_rows = limits.max_buffer_size.checked_sub(FAILURE_BYTES).ok_or_else(|| limit("GPU readback cannot fit diagnostics"))?
/ (u64::from(width) * 16 * if adaptive { 2 } else { 1 });
let mut rows = u64::from(height).min(storage_rows).min(readback_rows);
if filter {
let window_rows = storage / (u64::from(width) * super::denoise::GUIDE_BYTES_PER_PIXEL);
if window_rows < u64::from(height) {
let core_rows = window_rows
.checked_sub(u64::from(halo) * 2)
.ok_or_else(|| limit("GPU storage cannot fit one denoising row with its complete filter halo"))?;
rows = rows.min(core_rows);
}
}
// Each diagnostic core owns complete supersampling cells.
if rows < u64::from(height) {
let scale = u64::from(options.supersampling);
if scale == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "GPU image page supersampling must be nonzero"));
}
rows -= rows % scale;
}
let rows = u32::try_from(rows).map_err(|_| limit("GPU image page row count exceeds its address space"))?;
if rows == 0 {
return Err(limit("GPU storage cannot fit one complete image row and its required scratch"));
}
let layout = Self { width, height, rows, halo, planes, optical_bytes, filter, adaptive };
if layout.buffer_bytes(layout.max_core_pixels())? > storage || layout.readback_bytes()? > limits.max_buffer_size {
return Err(limit("GPU image page allocation exceeds the selected adapter limits"));
}
Ok(layout)
}
#[cfg(test)]
pub(super) fn with_rows(self, rows: u32, scale: u32) -> Result<Self> {
let rows = rows.min(self.rows);
if rows == 0 || scale == 0 || (rows < self.height && !rows.is_multiple_of(scale)) {
return Err(limit("test GPU page rows must contain complete supersampling cells"));
}
Ok(Self { rows, ..self })
}
pub(super) fn is_paged(self) -> bool {
self.rows < self.height
}
pub(super) fn page_count(self) -> u32 {
self.height.div_ceil(self.rows)
}
pub(super) fn max_core_pixels(self) -> u32 {
self.width * self.rows
}
pub(super) fn max_window_pixels(self) -> u32 {
self.width * self.height.min(self.rows + self.halo * 2)
}
pub(super) fn buffer_bytes(self, pixels: u32) -> Result<u64> {
u64::from(pixels)
.checked_mul(16 * u64::from(self.planes))
.and_then(|bytes| bytes.checked_add(self.optical_bytes))
.ok_or_else(|| limit("GPU image page byte count overflow"))
}
pub(super) fn optical_scratch_bytes(self) -> Result<u64> {
self.optical_bytes.checked_mul(u64::from(self.page_count())).ok_or_else(|| limit("GPU optical page scratch overflow"))
}
pub(super) fn readback_bytes(self) -> Result<u64> {
let film = u64::from(self.max_core_pixels()) * 16 * if self.adaptive { 2 } else { 1 };
let filter = if self.filter { u64::from(self.max_window_pixels()) * 16 } else { 0 };
film.max(filter).checked_add(FAILURE_BYTES).ok_or_else(|| limit("GPU page readback byte count overflow"))
}
pub(super) fn filter_scratch_bytes(self) -> Result<u64> {
if !self.filter {
return Ok(0);
}
let normalized = if self.adaptive { 16 } else { 0 };
let gather = if self.is_paged() { 16 * u64::from(self.planes) } else { 0 };
u64::from(self.max_window_pixels())
.checked_mul(super::denoise::BYTES_PER_PIXEL + normalized + gather)
.ok_or_else(|| limit("GPU filter window byte count overflow"))
}
pub(super) fn windows(self) -> impl Iterator<Item = Window> {
(0..self.height).step_by(self.rows as usize).map(move |row| {
let end = self.height.min(row + self.rows);
let first = row.saturating_sub(self.halo);
let last = self.height.min(end + self.halo);
Window {
start: first * self.width,
pixels: (last - first) * self.width,
core_start: row * self.width,
core_pixels: (end - row) * self.width,
}
})
}
}
pub(super) struct FilmPage {
pub start: u32,
pub pixels: u32,
pub accumulation: wgpu::Buffer,
pub bind: wgpu::BindGroup,
}
+115 -67
View File
@@ -1,109 +1,157 @@
//! Conservative, bounded convex-parent nesting certificate. AABB containment is
//! only a candidate; parent convexity and child vertices are checked against
//! every outward parent half-space. Intersections and touching boundaries refuse.
//! Exact retained-f32 surface contact and oriented containment. Bounds accelerate
//! candidate selection; disjoint overlapping bounds never imply nested media.
use super::scene::{Triangle, reserved};
use solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
use solidcraft_render::job::{CancelToken, ErrorCode, JobError, Result};
use solidcraft_render::{
job::{CancelToken, ErrorCode, JobError, Result},
optical_boundary::{ContactIndex, forbidden_contact, origin_crossing},
};
use std::ops::Range;
pub(super) struct Region {
pub(super) struct Region<'a> {
pub lo: Vec3,
pub hi: Vec3,
pub closed: bool,
pub triangles: Range<usize>,
pub medium: u32,
pub name: &'a str,
}
fn invalid(message: &str) -> JobError {
JobError::new(ErrorCode::InvalidScene, message)
}
fn disjoint(a: &Region, b: &Region) -> bool {
fn disjoint(a: &Region<'_>, b: &Region<'_>) -> bool {
a.hi.x < b.lo.x || b.hi.x < a.lo.x || a.hi.y < b.lo.y || b.hi.y < a.lo.y || a.hi.z < b.lo.z || b.hi.z < a.lo.z
}
fn box_contains(a: &Region, b: &Region) -> bool {
a.lo.x <= b.lo.x && a.lo.y <= b.lo.y && a.lo.z <= b.lo.z && a.hi.x >= b.hi.x && a.hi.y >= b.hi.y && a.hi.z >= b.hi.z
fn tick(work: &mut u64, cancel: &CancelToken) -> Result<()> {
cancel.check()?;
*work = work.checked_add(1).ok_or_else(|| invalid("optical certificate work overflow"))?;
if *work > 1_000_000_000 {
return Err(JobError::new(ErrorCode::LimitExceeded, "optical surface certificate exceeds one billion bounded checks"));
}
Ok(())
}
fn check_halfspaces(parent: &[Triangle], child: &[Triangle], strict: bool, work: &mut u64, cancel: &CancelToken) -> Result<()> {
for face in parent {
let normal = (face.p[1] - face.p[0]).cross(face.p[2] - face.p[0]);
let length = normal.len();
if !length.is_finite() || length <= 1e-12 {
return Err(invalid("invalid parent medium plane"));
fn winding(region: &Region<'_>, triangles: &[Triangle], point: Vec3, work: &mut u64, cancel: &CancelToken) -> Result<bool> {
let mut sums = [0_i32; 3];
// The topology certificate allows numerical CAD seams, while these tests
// use the uploaded coordinates. A ray can escape through such a seam.
// Cyclic permutations retain orientation and check all three axes without
// moving geometry or perturbing the containment sample.
let rotate = |p: Vec3, axis| match axis {
0 => p,
1 => Vec3::new(p.y, p.z, p.x),
_ => Vec3::new(p.z, p.x, p.y),
};
for triangle in triangles.get(region.triangles.clone()).ok_or_else(|| invalid("missing optical containment boundary"))? {
tick(work, cancel)?;
for (axis, sum) in sums.iter_mut().enumerate() {
*sum += i32::from(origin_crossing(triangle.p.map(|p| rotate(p, axis)), rotate(point, axis))?);
}
let n = normal / length;
for triangle in child {
for point in triangle.p {
*work += 1;
if (*work).is_multiple_of(2048) {
cancel.check()?;
}
if *work > 50_000_000 {
return Err(JobError::new(
ErrorCode::LimitExceeded,
"nested-medium half-space validation exceeds 50 million bounded checks; simplify only the selected optical boundary explicitly",
));
}
let scale = point.x.abs().max(point.y.abs()).max(point.z.abs()).max(1.0);
let tolerance = (scale * f64::from(f32::EPSILON) * 4.0).max(2e-6);
let distance = (point - face.p[0]).dot(n);
if !distance.is_finite() || (strict && distance >= -tolerance) || (!strict && distance > 2e-6) {
return Err(invalid(if strict {
"nested dielectric is not strictly inside the certified convex parent; touching or intersecting surfaces are unsupported"
} else {
"nested-medium parent must be geometrically convex; bounding-box containment is not a certificate"
}));
}
}
if sums.iter().any(|sum| *sum != sums[0]) {
return Err(invalid(&format!("inconsistent optical containment across uploaded seams for {}", region.name)));
}
match sums[0] {
0 => Ok(false),
1 => Ok(true),
_ => Err(invalid(&format!("ambiguous optical containment winding for {}", region.name))),
}
}
/// Certify all optical self/cross contacts with the actual uploaded coordinates.
/// This covers nonconvex surfaces without a triangle-pair scan.
fn contacts(regions: &[Region<'_>], triangles: &[Triangle], work: &mut u64, cancel: &CancelToken) -> Result<()> {
let count = regions.iter().try_fold(0_usize, |n, r| n.checked_add(r.triangles.len()).ok_or_else(|| invalid("optical contact count overflow")))?;
let mut ordinals = reserved(count)?;
for region in regions {
for i in region.triangles.clone() {
if ordinals.len().is_multiple_of(1024) {
cancel.check()?;
}
ordinals.push(i);
}
}
let mut index = ContactIndex::new(count)?;
while !index.step(|i| ordinals.get(i).and_then(|at| triangles.get(*at)).map(|t| t.p), cancel)? {
tick(work, cancel)?;
}
for (i, at) in ordinals.iter().enumerate() {
let a = triangles.get(*at).ok_or_else(|| invalid("missing optical contact face"))?;
let mut query = index.query(a.p)?;
loop {
tick(work, cancel)?;
let Some(candidate) = index.query_step(&mut query, cancel)? else { break };
let Some(j) = candidate.filter(|j| *j > i) else { continue };
let other = *ordinals.get(j).ok_or_else(|| invalid("missing optical contact ordinal"))?;
let b = triangles.get(other).ok_or_else(|| invalid("missing optical contact candidate"))?;
if forbidden_contact(a.p, b.p, a.medium == b.medium) {
let identify = |id, triangle| {
regions.iter().find(|r| r.medium == id).map_or_else(
|| format!("unknown triangle {triangle}"),
|region| format!("{} triangle {}", region.name, triangle - region.triangles.start),
)
};
return Err(invalid(&format!(
"glass boundaries intersect, touch, overlap or self-intersect: {} / {}",
identify(a.medium, *at),
identify(b.medium, other)
)));
}
}
}
cancel.check()
Ok(())
}
/// Return immediate parent medium identities, with zero for air, independent of
/// body order. A maximum of 16 containing media matches the device stack exactly.
pub(super) fn parents(regions: &[Region], triangles: &[Triangle], allow_nested: bool, cancel: &CancelToken) -> Result<Vec<(u32, u32)>> {
if regions.len() > 32 {
return Err(invalid("at most 32 dielectric bodies are supported"));
/// Immediate parent identities are independent of body order. Externally
/// disjoint interlocking boundaries remain children of air. Actual containment
/// retains the explicit nesting and disabled-shadow-shortcut requirements.
pub(super) fn parents(regions: &[Region<'_>], triangles: &[Triangle], allow_nested: bool, cancel: &CancelToken) -> Result<Vec<(u32, u32)>> {
if regions.is_empty() {
return Ok(Vec::new());
}
let mut contains = [[false; 32]; 32];
let mut convex = [false; 32];
if regions.len() > solidcraft_render::job::MAX_GPU_DIELECTRICS {
return Err(invalid("at most 128 dielectric bodies are supported"));
}
let mut contains = [[false; 128]; 128];
let mut work = 0;
contacts(regions, triangles, &mut work, cancel)?;
for (i, a) in regions.iter().enumerate() {
for (j, b) in regions.iter().enumerate().skip(i + 1) {
cancel.check()?;
tick(&mut work, cancel)?;
if disjoint(a, b) || (!a.closed && !b.closed) {
continue;
}
if !allow_nested {
return Err(invalid("nested or overlapping dielectric bounds require explicit nested_media and a containment certificate"));
}
if !a.closed || !b.closed {
return Err(invalid("a thin-sheet boundary may not overlap a closed nested medium"));
}
let (parent, child, pi, ci) = if box_contains(a, b) {
(a, b, i, j)
} else if box_contains(b, a) {
(b, a, j, i)
} else {
return Err(invalid("dielectric overlap is not a strict containment hierarchy"));
};
let parent_triangles = triangles.get(parent.triangles.clone()).ok_or_else(|| invalid("missing parent triangles"))?;
let child_triangles = triangles.get(child.triangles.clone()).ok_or_else(|| invalid("missing child triangles"))?;
if !convex[pi] {
check_halfspaces(parent_triangles, parent_triangles, false, &mut work, cancel)?;
convex[pi] = true;
let ap = triangles.get(a.triangles.start).ok_or_else(|| invalid("missing optical containment sample"))?.p[0];
let bp = triangles.get(b.triangles.start).ok_or_else(|| invalid("missing optical containment sample"))?.p[0];
let ab = winding(a, triangles, bp, &mut work, cancel)?;
let ba = winding(b, triangles, ap, &mut work, cancel)?;
if ab && ba {
return Err(invalid("cyclic optical containment"));
}
check_halfspaces(parent_triangles, child_triangles, true, &mut work, cancel)?;
if parent_triangles.iter().chain(child_triangles).any(|t| t.transmission.is_some_and(|m| m.shadow_transmission())) {
if !ab && !ba {
continue;
}
if !allow_nested {
return Err(invalid("nested dielectric bounds require explicit nested_media and a containment certificate"));
}
if triangles[a.triangles.clone()]
.iter()
.chain(triangles[b.triangles.clone()].iter())
.any(|t| t.transmission.is_some_and(|m| m.shadow_transmission()))
{
return Err(invalid("nested media require disabled straight shadow transmission; true refracted paths remain active"));
}
contains[pi][ci] = true;
if ab {
contains[i][j] = true;
} else {
contains[j][i] = true;
}
}
}
let mut result = reserved(regions.len())?;
for (child, region) in regions.iter().enumerate() {
let depth = contains.iter().filter(|parents| parents[child]).count();
if depth >= 16 {
if depth >= solidcraft_render::job::MAX_NESTED_MEDIA {
return Err(JobError::new(ErrorCode::LimitExceeded, "at most 16 nested dielectric layers are supported"));
}
let mut parent = 0;

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