Compare commits

...

36 Commits

Author SHA1 Message Date
selune 2a4707404a Settle appearance panel scrolling before targeting Search (#6079)
* claim: #6076

* test(render): settle appearance scroll before resolving Search (#6076)

Expanded starter swatches moved under the scenario click while the existing scroll animation was active. Advance 20 deterministic harness frames before resolving the Search handle. No wall-clock sleep, timeout change, assertion deletion or production change. Original native reproduction fails at the empty-state assertion; all 124 scenarios now pass with one pre-existing ignored adhoc entry.
2026-10-24 15:41:29 +02:00
selune cad0767a10 Merge main synchronization into render (#6098)
Resolves #6050 branch conflict while retaining both original nextest policies. Fresh quick run 40097215892 passed. Preserve main ancestry; no advanced experimental feature or full-green claim.
2026-10-24 15:17:44 +02:00
0xlillith 644a70960e Merge pinned main into render; preserve both independent nextest additions
Refs #6050, #5493. Manual resolution is confined to .config/nextest.toml and retains the existing CI gpu-path policy and main local-ci/windows-gpu policy unchanged. Parsed TOML semantics and automatic merge-tree equality for every other path are verified. cargo-nextest is not installed locally; native nextest parsing is NOT claimed. Fresh repository/compile checks remain required. No advanced experimental code is introduced.
2026-10-24 15:12:23 +02:00
0xlillith c2de6c636d claim: resolve the main/render synchronization conflict for #6050 2026-10-24 15:11:44 +02:00
0xlillith 6fbcb5f1ea feat(render): expose bounded original appearance recipes (#1722) 2026-10-24 14:10:09 +02:00
selune d94b0a1a7b GPU: match index-matched Fresnel in path and viewport (#6036)
Fixes #6035. Preserve actual GPU/f64 material-oracle checks in both translations. Scoped Vulkan, render, Clippy, WASM and fresh quick checks pass. No CI configuration or gate changes.
2026-10-24 13:51:31 +02:00
selune a29a45eca1 Render: isolate matched GGX visible-normal sampling (#1654)
Independent numerical constituent of #5493. Part of #1654; the CPU reflection consumer remains #1698, so this does not close its image acceptance. Original sampler, matched PDF and analytic/hostile-input tests are preserved from the reviewed donor. 189 render tests, strict Clippy, wasm32 and repository gates pass; fresh quick run 40086624771 passes. No CI configuration, saved-data or kernel change.
2026-10-24 12:41:21 +02:00
selune f443a388cc Merge pull request #5980 from 0xlillith/sync/render-main-ancestry-polish-20261024
Restore main ancestry after #5976 (merge commit only; no file changes)
2026-10-24 11:17:59 +02:00
0xlillith e4cceb3db1 Restore main ancestry after the accepted render synchronization
History-only follow-up to #5976 under #5493. The accepted squash d8c2abdb has the correct tree but omits main ancestry. Normal merge of the accepted original sync head restores that ancestry with ZERO file changes. Both source/merge trees are verified identical. Must be merged with Create a merge commit, never squash/rebase; no shared branch rewrite or direct main push.
2026-10-24 10:57:00 +02:00
selune d8c2abdb2b Sync main into render and preserve GPU CLI help (#5976)
* CLI: --help on each subcommand prints its usage and exits 0 (#4538) (#4545)

* claim: #4538

* CLI: --help on each subcommand prints its usage and exits 0 (#4538)

Every command now answers `--help` and `-h` with its own usage on stdout and
exit 0, the way the top level has since #1828. Before, each subcommand
answered differently: `fuzz --help` started a fuzz run with the default seeds
and ran until it was stopped, `recipe --help` took the flag for a file name,
and `snapshot`, `render`, `oracle`, `run`, `eval`, `script` and `crashes`
answered with an error on stderr and exit 1.

The help is read before the command runs, so `--help` never starts work: no
fuzz run, no MCP server, no file read.

`fuzz` also refuses an argument it doesn't take, as `snapshot` already did: a
flag it doesn't know and a stray word are errors, not arguments to ignore
while a hundred seeds run.

The usage lives in one place now: each command's lines are a constant, and the
top-level usage is composed from them, so the two cannot drift apart. The
top-level usage is unchanged, byte for byte.

# Conflicts:
#	apps/solidcraft-cli/src/main.rs

* Renaming a parameter follows it into sheet-metal and plastic rules and canvases (#4420) (#4423)

* claim: #4420

* Renaming a parameter follows it into sheet-metal and plastic rules and canvases (#4420)

Document::rename_param rewrote other parameters, feature inputs, dimensions,
configurations and joints, but not the expressions of sheet metal and plastic rules or
the plane a canvas lies on, so after a rename the sheet metal and plastic features
using them failed with "unknown parameter" and the canvas left the view. It renames
them there now, and param_users lists them (a rule's field, a canvas's input), so a
parameter they use can't be deleted.

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

* Parameters: rules' features in drives and the graph; shared rule field lists (#4420)

A sheet metal or plastic rule that uses a parameter makes the sheet metal (or plastic)
features rebuild when it changes, so parameters.users lists them in drives and
parameters.graph links them. SheetRule::fields/fields_mut and PlasticRule::fields_mut
replace the hand-written copies of the rules' fields in params.rs, eval.rs and
sheet.manage_rules. Suggested by @CamWight.

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

* fmt (#4420)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Mirror and pattern: a pattern or mirror feature as the source (#395) (#3998)

* claim: #395

* Mirror and pattern: a pattern or mirror feature as the source (#395)

A pattern or mirror of features refused another pattern or mirror as a
source ("Pat cannot be patterned or mirrored yet"). Such a source now
stands for what it copied: its own sources are replayed at each of its
transforms moved by each of ours, to any depth up to eight. Copies of
a feature picked directly keep the instance numbers they had, so
existing designs keep their face names; copies made through a nested
pattern or mirror are numbered after them, so one feature reached two
ways never names two copies alike. A pattern or mirror of bodies as
the source is still refused.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Expression completion: keep longer names visible when the typed word is a unit (#3663) (#3664)

* claim: #3663

* Expression completion: keep longer names listed when the typed word is a unit (#3663)

Typing `rad` into an expression box showed nothing because `rad` is the radians unit and
a word that is itself a known token closed the popup. The popup now stays open on such a
word. A complete name (`width` with `width_extra` defined) opens it unarmed: the longer
names are listed with no row selected, Enter submits what was typed and Tab moves on; Up
or Down picks a row, after which Enter or Tab complete it as before. A unit means
something only after a number, so a unit typed anywhere else (`rad` on the way to
`radius`) is a prefix like any other: the popup is armed and Enter completes `radius`
instead of submitting one radian.

A complete token is extended only by names that start with it (`in` lists `inner`, not
`rounding`), a typed unit offers no other units (`in` does not list `inch`), and a unit
right after a number (`2 in`, the form every dialog starts its sizes in) opens no popup at
all, so nothing sits over a dialog's OK button.

Fixes #3663

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Homero Thompson <vdirienzo@gmail.com>
Co-authored-by: SS-EN <84559909+SS-EN@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Pålsson <martin.palsson@outlook.com>
Co-authored-by: Daniel <daniel.owsianski@gmail.com>
2026-10-24 04:51:48 -04:00
0xlillith 81d3e4cb14 Merge pinned main into render, retaining its GPU CLI help
Periodic normal main-to-render synchronization under #5493. Preserve all four main commits and both parents. Resolve the single CLI help conflict by keeping main subcommand help dispatch and the renderer progressive/lighting/environment options; add a regression checking both top-level and render help. No renderer/kernel/format/workflow source is otherwise edited. Formatting/diff checks pass; fresh quick CI gates the merge. Current private combined execution retains its older exact source pin.
2026-10-24 10:49:29 +02:00
selune 5c0e48ed2b feat(render): add bounded linear film accumulation (#1696) (#1697)
Extract the independent FilmAccumulator from owned donor 3b892eab5e. Keep the existing job cancellation and caller-owned admission/work contracts. PNG resolution, primary camera coverage and progressive consumer wiring remain separate. Add analytic statistics, atomic refusal, allocation/range boundaries and real 4096/1048576-count tests. No kernel, document schema, workflow or capability change.
2026-10-24 08:48:08 +02:00
selune ce1c8db7fb feat(render): reconcile definition-local visual mapping (#1214) (#1223)
Retain the original mapping implementation, all eight analytic/hostile-input tests and mapping contract byte-for-byte from #1223. Reconcile only module registration and the current roadmap on render. Actual current render library 166/0; strict all-target render Clippy, wasm32 and repository gates pass. No persistence, sampler, new capability, kernel/vendor change or external code is added. Historical mixed-base full-CI failures are not relabelled.
2026-10-24 08:31:59 +02:00
selune f0995dab1c Merge pull request #5871 from 0xlillith/sync/render-main-ancestry-20261024
History-only: retain main ancestry in render (merge, not squash)
2026-10-24 08:12:54 +02:00
0xlillith 600f12ea0e Restore pinned main ancestry after the render synchronization squash 2026-10-24 08:10:54 +02:00
selune d4ec5e6811 Sync pinned main into render (#5869)
* Viewport: show reference geometry through bodies (Preferences, off by default) (#2697)

A new Graphics preference, "Show reference geometry through bodies", draws
inactive sketch contours, origin and construction planes and their axes over
shaded bodies. It is off by default, so the viewport looks as before: reference
geometry is depth-tested and a section leaves it whole (#1229); Create Sketch
and dialogs picking a plane still draw the planes through the model.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ui-egui tests: offscreen GPU tests start their devices one at a time (#3989) (#3990)

* claim: #3989

* ui-egui tests: offscreen GPU tests start their devices one at a time

Every offscreen test started a wgpu instance of its own, and starting one
makes the Vulkan loader scan its drivers. With several test threads two
tests doing that at once crashed the test process with SIGSEGV inside the
loader (loader_icd_scan). offscreen_device now holds a process-wide lock
while it starts the instance, adapter and device; each test keeps its own
device and the rendering still runs side by side.

* DXF: write and read a Bézier at its own degree (#5269) (#5281)

* claim: #5269

* DXF: write and read a Bézier at its own degree (#5269)

The DXF writer and reader held a spline's degree to 7, the value MAX_BEZIER_DEGREE had before
#1642 raised it to 9. A G4 blend (one Bézier of degree 9) was written as a degree-7 SPLINE over
its first 8 control points, so a flat pattern's cut-out stopped 4.444 mm short in the file.

- seg_shape takes its limit from MAX_BEZIER_DEGREE: a Bézier is written whole, at its own degree.
- read_dxf reads SPLINE degrees 1 to MAX_BEZIER_DEGREE, the degrees a sketch spline takes.
- Tests: every Bézier degree 4 to 9, a degree-9 round trip, the reader's degrees, hostile SPLINE
  degrees and too few control points, the G4 cut-out exported and read back with its areas, and a
  degree-9 SPLINE with 2 to 10 points inserted into a sketch.

Fixes #5269

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Renaming a plane keeps its projections, canvases and hidden state (#4353) (#4354)

* claim: #4353

* Renaming a plane keeps its projections, canvases and hidden state (#4353)

timeline.rename only renamed the feature in other features' references. A sketch's
projection of a construction plane, a canvas placed on it and the hidden list keep it
by name too, so they lost it: the sketch warned that the plane no longer exists, the
canvas left the view and a hidden plane showed again. Document::rename_feature_keys
renames it in sketch links and canvases, as rename_body_keys does for bodies; the
selection follows in the command, and the hidden list in Session::refresh_from, so a
hidden plane stays hidden through undo and redo too.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: add Rename and Delete to feature menus (#3839, #4343) (#4342)

* claim: #3839

* fix(ui): add Rename and Delete to browser feature menus

* Browser: a second click on a Document Settings entry closes its window (#4150) (#4152)

* claim: #4150

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

* Browser: a second click on a Document Settings entry closes its window (#4150)

Each entry under Document Settings only opened its window. A second click on
Units closed the menu on the press (the row is outside it) and opened it again
on the release; Precision left Preferences open; Parameters and Design
Properties came back as a fresh copy. Now a click on an entry whose window is
open closes it the way the window's own x does:
- Units: context_menu::toggle_for with the row as the opener, the fix #1522
  made for the workspace button's menu. A right-click still opens it at the
  pointer.
- Precision: prefs::close(app, true), the Cancel/x path that puts back what
  changed since the window opened (now pub(crate)).
- Parameters and Design Properties: the window is dropped, as its x does. A
  Parameters value typed but not applied yet is dropped with it, as today's
  second click already drops it.

Fixes #4150

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preserve expression-positioned sketch fillet corners (#4097) (#4106)

* claim: #4097

* Sketch fillet keeps a corner placed by an expression (#4097)

Filleting a corner whose position comes from parameter expressions deleted
the point and its expressions, so the sketch gained two degrees of freedom
and later parameter changes distorted the outline. keep_corner now also
keeps fixed or expression-positioned points as virtual sharp corners.

* Fix feature editor picks after upstream sketch resizing (#4083) (#4084)

* claim: #4083

* Feature editors refill named edges and faces where they are now (#4083)

After an upstream sketch change, Fillet, Chamfer, Shell and Draft evaluated
correctly, but their editors refilled the originally clicked points and
could then fail with "no edge at ...". Resolve the stored names through the
document's naming helpers first, keep the resolved index, and map the pick
into the displayed body's frame; the point lookup stays as the fallback.

* ui-egui tests: clear the texture deltas of the frames they run (#4039)

Nine tests dropped an egui frame's output with its texture delta still
unapplied (the expression completion editor's frames, the slot preview's
three, the browser's feature rows). epaint asserts against that in debug
builds, so they failed under the dev profile and passed only where debug
assertions are off. They clear the delta as the other tests do; one more
hover test that dropped its output the same way does too.

* Frames: profile library, members along frame sketch lines, and the Frame Member dialog (#4003) (#4004)

* claim: #4003

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

* Frames: the profile library (#4003)

The first slice of frames and weldments: the cross-sections structural members
will be made of, each SolidCraft's own parametric definition built in code
(no vendor or standard size tables are shipped).

- solidcraft_doc::frame: bar, round bar, rectangular and round tube, angle,
  channel, I-beam and a generic T-slot extrusion as exact line/arc regions,
  with defaults worked out from the profile's size, refusals that name the
  dimension that does not fit, anchors and placement, and exact section
  properties (area, centroid, Ixx/Iyy/Ixy by Green's theorem).
- frame.profile.list, frame.profile.properties (with mass per metre for a
  density) and frame.profile.insert (draws a profile into a sketch, locked by
  default, at a point, anchor and angle).
- Tests against hand-computed values (rectangles, circles, composite
  rectangles by the parallel-axis theorem, fillet spandrels), hostile input,
  extrusion volume, undo; catalog entries in every locale; docs/frames.md;
  ROADMAP M15.

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

* Frames: drop the built-in T-slot profile (#4003)

Aluminium extrusions and other vendor sections come from the user's own
sketch (drawn or inserted from the vendor's DXF), so the library keeps the
plain steel shapes only.

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

* Frames: members along frame sketch lines (#4003)

frame.member lays one body per straight line of a frame sketch (2D lines or
3D sketch lines), its cross-section square to the line from end to end:

- profile: a built-in profile, or profile_sketch, a sketch whose material
  (nested loops alternating, so two circles make a tube) is the section in
  its own x and y around its origin; profiles picks override it.
- anchor (origin, centre, centroid or a bounding-box corner or edge middle),
  angle (an angle expression, so a parameter), mirror.
- Orientation: the profile's y as near world up as the line allows, x
  square to both; a vertical member's x is world X (frame::member_axes).
- Members follow sketch and parameter edits, undo as one step, save and
  reopen (format: pending/frame-members.rs), and are named after the profile
  (Rect Tube 40x40x3, (2)…), numbering on past names already taken.

Tests against hand-computed volumes and bounding boxes (rectangle, 3D
tower lines, parameter edit, sketch tube profile and picks, anchor/angle/
mirror), refusals, undo/redo and reopen; walk fixtures and goldens; docs.

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

* Frames: fixes from the adversarial review (#4003)

- Default member lines are what was drawn: projected and other linked curves
  and 3D wires (a face sketch's edges) and lines of no length are left out.
- A profile sketch's material starts from its drawn profiles, as
  solid.extrude's "all" does, so a section sketched on a face works; the
  even-odd test uses outlines and boxes worked out once, capped at 256.
- Member names are pinned to their lines (`names`, by line id; `body_name`
  is the base): deleting or adding a line, or deleting another feature, no
  longer renames other members, and frame.member stores every name it gives.
  body.rename renames a member on its line (also for later member features);
  a copy whose body_names were set still names its members by position.
- Built-in profile dimensions are expressions (ProfileSpec): they follow
  parameters, bare numbers take the design's units, and they are feature
  inputs. frame.profile.properties/insert read them the same way.
- Default root/toe radii shrink to the room a thick section leaves; radii
  given are checked by name; a radius must be 0 or at least 0.01 mm; error
  messages report the wall or flange value given, not twice it.
- A line within 1e-6 rad of vertical orients as vertical, so rounding never
  turns a post a quarter turn.
- A 2D frame sketch in a differently placed component is mapped into the
  member's frame, and member features recompute when placements change.
- Caps before lookups for a design's `lines`; a line listed twice is
  refused; a 3D wire that runs back on itself is not a line.

Regression tests for each, from the review's scenarios.

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

* Frames: the Frame Member dialog on the ribbon (#4003)

- Frame Member (MODEL > Create) opens a dialog: the frame sketch's lines
  picked in the view (or All Lines), a built-in profile and its dimensions or
  a section picked in another sketch, anchor, angle, mirror and name, with a
  live preview. Edit Feature reopens it filled in.
- Lines picked in the view keep their sketch (a SKETCH_LINES input picks
  ExternalSketchCurve): line ids are each sketch's own, so two 3D sketches
  both have a `w2`, and the members went on the newer sketch's lines.
- timeline.redefine takes frame.member. Members keep their names through an
  edit unless it gives them a new body_name; frame.member takes `names`.
- A member whose section is a whole profile sketch keeps it when edited
  (the dialog shows the sketch instead of picking every region, which would
  fill a tube's hole).
- An original frame_member icon, open for review, on the ribbon, timeline
  and browser.
- The angle profile is listed as L-Angle, and web and flange as Web/Flange
  thickness, so they translate apart from the existing Angle, Web and Flange;
  the dialog's labels are in en, ja and zh. en-GB no longer repeats the frame
  command labels it spells the same.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Rectangular and circular pattern: copies that miss the body are reported (#3042) (#3919)

* claim: #3042

* Warn when cut pattern copies remove no material

A rectangular or circular pattern whose copies miss the body succeeded silently and those copies did nothing. The pattern now reports how many of its cut copies removed no material, and keeps the copies that land.

* Delete in a rolled-back history warns about the features after the marker (#3471) (#3698)

* claim: #3471

* Delete in a rolled-back history warns about the features after the marker (#3471)

With the timeline rolled back, the checks a delete runs first (the
confirmation's "these features will fail", timeline.dependents and
selection.delete's would_fail) compared models evaluated only up to the
marker. Features after it, broken by the delete, were not listed, so their
error marks turned up only once the timeline moved to the end.

newly_broken_in_history evaluates both sides over the whole history when
either has a marker (on copies; the model and document are unchanged) and is
used by the delete's report and by timeline.dependents.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* macOS: Fusion navigation preset does not support Shift + two-finger orbit (#3446)

* Macos: keep touchpad/key modifiers true to fusion

* fix: remove obsolete orbit snap reset from trackpad navigation

Upstream removed ViewportState::orbit_snap. The new trackpad orbit path retained one reference, causing E0609 in the PR merge build.

Validation: all seven trackpad tests pass; cargo check -p solidcraft-ui-egui --all-targets passes on the exact failing CI merge 66a2791b with this fix applied; rustfmt and diff checks pass.

Co-Authored-By: GitHub Copilot <noreply@github.com>

* fix: retain Fusion trackpad help in refactored preferences

Preserve upstream's new preferences pages and apply the translated Fusion trackpad tooltip in prefs/navigation.rs.

Validation: cargo build -p solidcraft passed. Tests and full CI were not run, as requested.

Co-Authored-By: GitHub Copilot <noreply@github.com>

* style: format Fusion trackpad preferences tooltip

Apply rustfmt's required wrapping for the relocated tooltip. No behavior changes.

Validation: cargo fmt --all -- --check and git diff --check passed.

Co-Authored-By: GitHub Copilot <noreply@github.com>

---------

Co-authored-by: GitHub Copilot <noreply@github.com>

* 2-point circle takes a typed diameter (#3278) (#3335)

* claim: #3278

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

* 2-point circle takes a typed diameter (#3278)

After its first point the 2-point circle tool showed no value box, so the
circle could only be sized with the cursor. It now shows a Diameter box,
like the centre circle: the typed value puts the second point that far
along the cursor's direction, and Enter draws the circle.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Reorder design tabs by dragging them (#3187) (#3188)

* claim: #3187

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

* Reorder design tabs by dragging them (#3187)

A tab dragged along the strip follows the pointer while the others make
room, and lands where it is released; Escape, or releasing down the
window, leaves the order as it was. With the custom title bar a drag that
starts mostly up or down (or on the only tab) still moves the window.
The control channel's ui.documents gains a move action (from, to).

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Renaming a body keeps the evaluated timeline, off the window thread (#2467) (#2468)

* Renaming a body that later features change evaluates nothing (#2467)

Model::rename_body now keeps the results when later features change the
body (a pattern, a chamfer, a join). It still evaluates when a body of
that name comes and goes or isn't the same body throughout, when a later
body's name is worked out from it (X (2)), or when a message quotes it.

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

* Rename box: rename off the window thread, and locked bodies follow a rename (#2467)

The rename box commits body.rename through run_heavy, so a rename that
still evaluates later features (one names the body in its targets)
computes beside the window instead of freezing it. Locked bodies now
follow a body.rename from anywhere (the rename box, the control channel,
scripts), not only the rename box.

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Toolbar: a constraint dragged out of the small box becomes a full button (#5254) (#5456)

* claim: #5254

* Toolbar: a constraint dragged out of the small box becomes a full button (#5254)

The Constrain panel's small box of constraint icons was a fixed list, so
dragging one onto the full-size buttons saved a new order but it was still
drawn in the box. The toolbar settings now remember, per tab, which box
commands were moved out; those are drawn as full buttons, the box closes
up, and dropping one back on a box icon returns it. Reset Toolbar restores
the default box, and settings saved before this load unchanged.

* Sketch offset keeps its source lines free to turn (#4464) (#4466)

* claim: #4464

* Sketch offset keeps its source lines free to turn (#4464)

An offset adds a Parallel between each source line and its offset line.
Since offsets became associative, the offset's points are held during a
solve, so each Parallel pinned its source line to the offset line's
current direction: a closed chain lost its turning freedom (8 degrees of
freedom became 6), and Horizontal or an angle edit on a source line was
refused as conflicting.

The solver and the constructive reading now leave out a Parallel that
ties an offset line to the source it follows while the association lives;
the association already keeps them parallel. After Break the stored
Parallel drives again.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Flat Pattern view draws perforations (#4440) (#4441)

* claim: #4440

* Flat Pattern view: draw perforation holes (#4440)

The flat view drew the outline, cut-outs, pockets and bend lines but not
perforation fields, though the flat-pattern data and the DXF export list
their holes. The view now reads the hole centres from the sheet through
SheetBody::perforation_holes, caches them per model state and body, and
draws them as circles in the cut-out style. Holes outside the viewport or
smaller than one pixel are skipped, and the cache is capped at one million
holes.

* "Don't Save" looks like a button in the Save changes prompt (#4380) (#4400)

* claim: #4380

* "Don't Save" looks like a button in the Save changes prompt (#4380)

The prompt's third action used the kit's low-emphasis text style, so it
didn't read as clickable and people thought Cancel was the only way out.
Add an opt-in secondary_left style to the confirm footer (a framed
secondary button from the theme tokens) and use it for Don't Save.

* Save prompt test: clear each frame's texture delta once it is applied

egui 0.36 panics when a TexturesDelta with unapplied deltas is dropped;
the test kept only the last frame's output and dropped the earlier ones.

* 3D mouse test: clear the frame's texture changes, so it passes in debug builds (#4382)

each_frame_hands_the_view_its_motion_through_the_drivers_mapping dropped
the output of a frame it ran (`let _ = ctx.run_ui(...)`). epaint panics
in debug builds when a TexturesDelta is dropped with deltas left ("Dropped
TexturesDelta with 1 unapplied deltas"), so the test failed under a plain
`cargo test` on every platform while the ci profile passed. It now clears
them, as the other UI tests do since #60.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Spanish (es-ES): catch up with en.json (#4292)

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>

* Esc ends the navigation bar's Pan, Orbit and Zoom modes (#4226) (#4233)

* claim: #4226

* Esc ends the navigation bar's Pan, Orbit and Zoom modes (#4226)

The nav bar toggled a mode on, but nothing cleared it on Esc, so a left drag
kept panning, orbiting or zooming. Esc now ends an active nav mode first;
the next Esc reaches the tool or dialog as before.

* Keep long Select Other labels inside the popup (#4209) (#4225)

* claim: #4209

* fix(ui): bound Select Other labels and expose full names (#4209)

* Deleting an inserted file removes its components, placements and joints (#4100) (#4113)

* claim: #4100

* Deleting an inserted file removes its components, placements and joints (#4100)

A STEP or mesh file inserted into a design makes a component tree. Deleting
its feature left that tree behind: the components, their occurrences, the
rigid joints that hold them and the bodies' component assignments. A later
body that took an imported body's name was then placed in the stale component.

Document::remove_component_tree removes a component subtree and everything
that refers to it: the features made inside it, its occurrences, the joints
on them with their motion links, joint origins, contact set members, motion
study keys and exploded moves, body component and offset entries, standard
part and link records, and configuration columns. Deleting an inserted
file's feature and component.delete both use it; component.delete now also
removes joints left pointing at a deleted occurrence. The body transfer
refusal moves with it, unchanged.

* Only an inserted file's feature takes its component tree when deleted

Mark the feature a STEP, IGES or mesh insert makes (`inserted`), and remove
the component tree on delete only for those and for a linked design's
feature. Deleting a copied component's independent base deletes just that
feature, as before. Older designs get the mark as they open (pending format
change), except copied components' independent bases.

* Rigid joint saved with the wrong occurrence when two occurrences share a component (#3857) (#3939)

* claim: #3857

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

* Joint dialog: a body of a further instance picks that occurrence, not the component's first (#3857)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Cancel Sketch: leave a sketch without keeping its changes (#3838) (#3938)

* claim: #3838

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

* Cancel Sketch: leave a sketch without keeping its changes (#3838)

sketch.cancel (Cancel Sketch, ribbon Exit group and the sketch's
right-click menu) puts the design back as it was when the open sketch
was created or opened for editing: a new sketch is removed with its
dimension parameters, an edited one returns to how it was. It is one
undo step, so Undo goes back into the sketch.

The session remembers the design and the undo history at the point a
sketch is entered. Cancel uses that only while the history still runs
through it (the same steps before, every step since taken in this
sketch); otherwise it refuses and points to Undo, so a stale entry can
never throw away later work.

The icon is the approved close glyph in the sketch accent (alias
sketch_cancel); ja and zh carry the new label.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 3MF import: name a Bambu Studio project's bodies after its objects (#3858) (#3859)

* claim: #3858 (3MF import: names from a Bambu Studio project)

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

* 3MF import: name a Bambu Studio project's bodies after its objects (#3858)

Bambu Studio leaves the name off the root model's objects and keeps it in
Metadata/model_settings.config (<config><object id="N"><metadata key="name">),
so every object of a project came in as Mesh1, Mesh2, ... A build item whose
root object has no name of its own now takes the object's name from those
settings. A name in the model still comes first, and part names, other
settings and objects of other model parts are left alone.

The settings are read only when a body needs a name, within what the model
parts left of the 1 GB budget. Settings that can't be read (malformed, nested
too deeply, too large) never fail the import: the bodies keep the generic
names, with a warning saying why.

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

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Viewport: right-click keeps a multi-selection of faces and edges (#3733) (#3788)

* claim: #3733

* Viewport: right-click keeps a multi-selection of faces and edges (#3733)

---------

Co-authored-by: AhmetV <344941595+ahmetv10@users.noreply.github.com>

* Parameter import and image pickers offer their own file types (#3216) (#3314)

* claim: #3216

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

* Parameter import and canvas image pickers offer their file types (#3216)

Both opened the picker with no file type of their own, so it listed only
designs, CAD files and meshes (and All files). macOS lets you pick only
the types a filter names: CSV parameters, and PNG or JPEG images for a
canvas, were greyed out. They now ask for csv/json and png/jpg/jpeg,
which the picker lists first (#1801).

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Return to the originating tab after finishing a sketch (#3250) (#3264)

* claim: #3250

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

* Return to the originating toolbar tab after finishing a sketch (#3250)

A sketch started from SHEET METAL or PLASTIC always ended on the SOLID
tab. Remember the tab that was active when the sketch began
(UiState::sketch_return_tab, not saved) and go back to it when the
sketch ends. Falls back to SOLID when that is not one of the normal
toolbar tabs. A sketch started from SOLID behaves as before.

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

* Test: no one-element loop in the return-tab test (clippy single_element_loop)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* STEP import: let a cancelled import release the import slot (#3171) (#3189)

* claim: #3171

* Release cancelled file import slots (#3171)

* Dragging a part along its joint no longer highlights what's under the pointer (#5137) (#5140)

* claim: #5137

* Dragging a part along its joint no longer highlights what's under the pointer (#5137)

The viewport's hover pick was skipped during a box selection and camera
moves, but not while a jointed part was dragged, so faces and edges under
the pointer lit up on every frame of the drag. The pick now waits until
the joint drag ends.

* Browser: a body picked in the viewport is opened to and scrolled to (#3005) (#3081)

* claim: #3005

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

* Browser: a body picked in the viewport is shown in the Browser (#3005)

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

* Merge main; viewport::select takes a toggle flag now

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Line tool snaps a diameter through the circle centre (#4326) (#4327)

A line drawn across a circle only snapped its end onto the curve, so a
diameter could miss the centre at any angle. Near the centre the line now
locks onto the radial line: on the rim it stays on the circle and the centre
is kept on the line, and on the centre point the end lands there.

* Flatpak: find the host's fonts for CJK text and the font list (#5124) (#5127)

* claim: #5124

* Flatpak: find the host's fonts for CJK text and the font list (#5124)

Inside the Flatpak sandbox the host's fonts are mounted at /run/host/fonts
and /run/host/user-fonts, which no font search looked at. Japanese and
Chinese text fell back to boxes, and the font list missed the host's
families.

- Search both folders for the CJK and Chinese fallbacks and for the
  installed-font list.
- The CJK fallback reads SOLIDCRAFT_FONT_DIRS (a path list, as the font
  list already does), with CRAFT_FONTS_DIR kept as a fallback.

* Crash reports: keep component.activate in the journal (#5079) (#5121)

* claim: #5079

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

* Crash journal keeps component.activate (#5079)

Activating a component changes only the session's active component and
occurrence path, not the design, so the journal dropped the command and a
crash report's replay built later parts under the previously active
component. The journal now also keeps a command that changes either.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Control channel and MCP on by default, with a setting to turn it off (#5561)

The app opens its localhost control channel on port 27878 (or the next free
one) unless Preferences > Assistant turns it off; --control and
SOLIDCRAFT_CONTROL_PORT still win. A current.port discovery file lets
solidcraft-cli mcp find the running app with no arguments.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Tests: moving an occurrence recomputes only the cuts and joins it touches (#4965) (#4982)

* claim: #4965

* Joins and cuts follow only the placements they depend on (#4965)

Tests that turning a joint between parts cuts no hole again, and that a
tool cutting another part cuts again when that part moves relative to it,
while an unrelated part moving keeps the result.

Booleans and Combine now hash only the placements of the bodies they take
as inputs, relative to the frame that evaluates them (#3397), so these
pin that behaviour for joins and cuts with named targets across parts.

* fix(ui): preview shortcut chords and wait for complete modifier input (#1810)

Keep physical modifier events out of the ordinary-key slot, preview held keys immediately, and confirm after release and a one-second settling interval. Preserve legacy control-channel modifier behavior while allowing explicit physical Control/Command input. Add regression coverage, a UI scenario, localized guidance and documentation.

Fixes #1809.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Move: scope box selection to the active component (#4459) (#4527)

* claim: #4459

* fix: scope Move box selection to active component (#4459)

* Delete removes a selected construction axis (#4692) (#4695)

* claim: #4692

* Delete removes a selected construction axis (#4692)

Delete on a construction axis skipped it with "the origin axes can't be
deleted". It now deletes the construction axis feature, like construction
planes; the origin axes X, Y and Z are still refused.

* Agent chat: end the agent's process group where `kill` is busybox' (#4587) (#4588)

* claim: #4587

* Agent chat: end the agent's process group with the system call (#4587)

The group was signalled with a `kill` program: `kill -s TERM -- -<pgid>`.
That works with procps' `kill` and with BSD's, but busybox' `kill` takes no
`--`: it prints its usage, exits 1 and signals nothing. The agent's helpers
(the CLI an adapter wraps, the MCP servers it starts) then stay alive after
the agent is ended.

The group is signalled with the system call instead, so no `kill` program and
no shell are needed and the behaviour is the same everywhere. `nix` (MIT) is
already in the tree through zip, and only on Unix: the browser build does not
take it.

The two tests that cover this now pass where they failed before:
`agent_chat::process::tests::dropping_the_process_ends_its_group` and
`agent_chat::process::tests::stopping_the_agent_leaves_no_helpers_running`.

* Extrude: picking a face, vertex or parallel plane off the profile switches the extent to To Object (#2855)

* Extrude: picking a face, vertex or parallel plane off the profile turns the extent to To Object

With profiles picked, a face off their plane (or curved), a vertex off it, or a
construction plane parallel to it is taken as the To Object target instead of
another profile: the extent switches without the Extent Type choice, and the
distance shows how far the target is. Coplanar faces still join the profiles.

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

* Extrude: automatic To Object is a Preferences setting, off by default

Preferences > Design > "Extrude to a picked target" (`extrude_auto_to_object`, default
false) turns the automatic To Object on. Off, picks in the Extrude dialog only add
profiles and the distance is not synced to a target, as before this change.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>

* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* FreeCAD/Rhino import: decode caps with room for detailed parts (#1783) (#3209)

* claim: #1783

* FreeCAD/Rhino import: decode caps with room for detailed parts (#1783)

cadmpeg decoded FreeCAD and Rhino files under its service profile, whose
million collection items and 2.56e8 work units a detailed part (a sprocket,
a threaded rod, a part with a few hundred faces) runs past: the import was
refused with "resource limit on CollectionItems: BudgetExceeded". Allow
8 million items and 2e9 work units, half of cadmpeg's desktop profile; the
input-size cap stays SolidCraft's STEP limit and every other cap stays at
the service profile's.

decoding_is_capped checks the two raised caps (and that they stay below the
desktop profile's) and that every other cap is still the service one.

Fixes #1783

* A spherical shell revolves into a solid with a void, and cuts and joins with hollow tools work (#451) (#4182)

* Assembly: ground the first part; drag free joint chains (#2804) (#2805)

* assembly(feat): ground first parts and drag free joint chains

Ground the first top-level part when it is created and let face dragging reposition free occurrences or any member of a floating joint chain in one undo step. Preserve exact repeated-placement frames, joint values, modifier selection and Escape cancellation. Keep the accumulated fixture, viewport layout and documentation integration fixes from the existing PR.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(fix): update movable fixtures for first-part grounding

Accept the documented grounded override in component creation, explicitly free movement fixtures, and keep linked clipboard pastes movable even in an empty document. Cover first and reused linked pastes, default insertion grounding, and single-step undo in the existing link tests.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(test): keep the motion-cost crank free

Unground the first component in the document-level joint-face measurement fixture so its motion samples can rotate after first-part grounding. Preserve the rotation and shared-measurement assertions that caught the Linux CI failure.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(test): free the frame placement fixture

Create the frame-member placement fixture ungrounded before moving it above its root sketch. Keep its world-space member assertions while respecting first-part grounding in the frame tests added on main.

Signed-off-by: Karl Von Richter<karl@embeint.com>

---------

Signed-off-by: Karl Von Richter<karl@embeint.com>

* UI: integrate theme radii, borders, palettes and viewport refresh (#5045) (#5046)

* claim: #4922

* UI: let themes own corner radii

* UI: use four-point panel and window radii in SolidCraft 95

* UI: align the 95 radius assertion with four-point panels

* claim: #4953

* UI: let themes own structural and control borders

* UI: single Blueprint outlines and dotted secondary rules

* UI: separate container outlines and add theme-owned 95 bevels

* claim: #5007

* UI: apply theme palettes to icons, workspace tabs and Gruvbox settings

* claim: #5045

* Viewport: invalidate cached geometry colours when the theme palette changes

* UI: preserve legacy theme logo colours with the borderless mark

* Section Analysis: OK with no plane picked turns the section off (#2410) (#2411)

While a section is on, Section Analysis opened with no plane picked now
says "No plane picked: OK turns the section off." and OK runs
inspect.section {"clear": true}. With no section on, OK with no plane
still asks for a plane.

Fixes #2410.

* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* Close Change Parameters with Escape (#3372) (#3399)

* claim: #3372

* Fix Escape cancellation in Change Parameters

Consume Escape before parameter editors can commit pending text on focus loss, while preserving autocomplete and shortcut capture priority. Add 11 UI regressions covering draft cancellation, preview cleanup, document and history preservation, and reopening saved values.

* Preserve construction references when axes and points are renamed (#4419) (#4449)

* claim: #4419

* fix: preserve renamed construction references (#4419)

* Browser: route body picks into open command dialogs (#2560) (#4371)

* claim: #2560

* fix(ui): route Browser body picks into active dialogs

* Fix midpoint picks in sketch dimensions (#4872)

Retain MID snaps across both picks and materialize reusable midpoint
constraints for point-to-line and point-to-point dimensions. Let midpoint
pairs choose horizontal or vertical spans with pointer placement before
entering a value.

Cover projected model edges, overlapping geometry, helper reuse,
undo/cancel/reopen, and existing angular and line-circle workflows.

* Sketch slots: provide typed dimension inputs for every variant (#2423) (#4318)

* claim: #2423

* fix(sketch): add typed dimensions to every slot tool (#2423)

* Version 0.21.0 on main (released from rc.2's code) (#5774)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs(mcp): Windows setup for Claude Desktop (#3794)

* docs(mcp): Windows setup for Claude Desktop (paths, two servers, Store config file)

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

* docs(mcp): say the Store build can keep the config elsewhere, not that the other path fails

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* MCP: wait out long requests, honest batch outcomes, batch tool aliases (#2901)

Rebased onto main as one commit (was 120d26f7, df488f9a, db9d1073 plus
merges of main).

- A request the app reports as still running (#1618) is waited for on the
  same connection through `control.pending`; past the wait the error says it
  was not cancelled and will still apply (or only write its file), later calls
  are not sent behind it, and the new read-only `pending` tool returns its
  result once it lands.
- A failed batch says what really happened: `rolled_back` only when the
  design is back where the batch started (as #3288 / #3242 on main: the app's
  `rolled_back` is believed; for an older app, every completed step undone;
  only a rollback asked for is reported as one), plus `applied`, `not_run` and
  `failed.still_running`.
- A batch step may name a tool (set_parameter, list_commands,
  describe_command, execute, measure, inspect_design, undo, redo, export, save,
  open, new_design, crash_reports); screenshot, body_topology, batch and
  pending are refused before anything is sent.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: driver choice, guided calibration, button mapping and a picture of the device (#1463) (#3825)

* Add a small test boat to steer around with the 3D mouse

A little hull in its own camera, drawn by the UI itself and not the CAD
viewport, so a 3D mouse can be tried out without opening a design. It
brings the model (generated by assets/models/make_test_boat.py) and the
tests for how the camera moves it.

Nothing uses it yet; Preferences picks it up with the rest of the 3D mouse
work.

* UI kit: keep a choice row from flipping with the scrollbar

A choice row picks segments or a dropdown by whether its labels fit. A page
scrollbar that comes and goes narrows the row by its own width, so the row
could flip back and forth between the two on a page that sits right at the
edge. It is now judged on the whole row and through a small helper,
fits_steadily, that only changes its answer once the miss is wider than the
scrollbar.

Also tests for that, for typed numbers in a slider's value box, and a
test hook to put up the issue-report input barrier by hand.

* 3D mouse: device model, guided calibration, buttons and a picture

The 3D mouse becomes a device we know something about instead of six raw
numbers.

The reader picks between spacenavd and the direct hidraw driver, falls back
from one to the other, and says so when it can't open the device. Guided
calibration asks whether the device is a spring cap or a trackball, adds a
touch step, and uses a settle filter so a ball let go of stops reading as
motion. Buttons get jobs (keys, held modifiers, commands), saved per
device, with defaults for the SpaceMouse Pro.

Preferences gets a picture of the device that moves as it does, next to the
test boat, and the same picture can sit in the 3D view at Left Viewport,
Right Viewport or Left Window, above the floating History card like the
legend and readouts.

These ride together because the crate's calibration types, the Preferences
page, the Linux reader and the button hook all use each other.

* 3D mouse: offer to calibrate an uncalibrated device

When a 3D mouse shows up that has not been calibrated, a card in the 3D
view offers to calibrate it, to swap drivers when the other one would work
better, to close, or to never ask again. It is made once per device, goes
away after 30 seconds in view, and gives way to a status or computing
toast instead of stacking on it.

The card is the shared toast, which now takes buttons: they group by
purpose, wrap inside the card at narrow widths, and do nothing while an
issue report holds CAD input. The picture of the device in the 3D view
keeps clear of the card.

* 3D mouse: translations and attribution

The 3D mouse strings (ui.3d_mouse_*, the calibration steps and the offer
card) in every shipped language, the Japanese catalog note, and the credit
for the test boat model in ATTRIBUTION.md.

This is last because the code before it only looks keys up: it builds and
runs without these, falling back to English until they land.

* 3D mouse: draw its preferences with the settings kit like the rest of Navigation

The 3D mouse section was a folded header with its own layout. It now
uses the same kit as the sections above it: a settings_section card,
settings_form rows with the shared label column, dividers between the
device, axes, picture and buttons groups, Text::Hint for the helper
lines, and settings_control_width for the dropdowns.

The section is open like every other one (the header still folds it),
so the device badge on the folded header is gone; the calibration row
already names the device. Every setting, string and key is unchanged.
Tests that unfolded the section no longer click it, and the hint lines
are looked up as hints instead of notes.

* 3D mouse: show each axis's live tilt in Preferences, like main's axis rows

Each view axis row is main's axis row again: a meter that follows the tilt of the motion driving it, with the dead zone marked, next to Invert, Speed and the "driven by" menu. The meter reads the calibrated travel when the device has a calibration and the raw reading over full scale when it does not, and asks for a frame whenever the reading changes. The helpers are back as they are on main, and the calibration state is a badge by the enable toggle.

* 3D mouse: line its preferences up with the rest of Navigation

- The two checkboxes sit side by side in the same two-column grid as Wheel and trackpad.
- Row labels stop repeating "3D mouse" and use sentence case: Calibration, Driver, Restore defaults, Reset view, Left viewport, Right viewport, Left window.
- Hints line up under the control column instead of the left edge.
- The six view axes are named Pan X, Pan Y, Zoom, Orbit X, Orbit Y and Roll so they fit the name column.
- Buttons are a divider and rows in a form like the other groups, without the bold sub-heading.
- The driver, dead zone, axes, picture and buttons sit behind a collapsed Advanced section, since most people never touch them.

* 3D mouse: tidy the Advanced test for clippy

* Timeline: suppressing a feature keeps later features' body names (#4302) (#4311)

* claim: #4302

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

* Timeline: suppressing a feature keeps later features' body names (#4302)

Bodies numbered by the counter (`BodyN`) take the next free number on every
recompute, so suppressing a feature that made one moved every later counted
body down one (`Body2` became `Body1`) and broke what named it, such as a
Move of `Body2`. timeline.suppress now stores the current names of later
features' counted bodies in their `body_names` first, as body.rename already
does for a renamed counted body (#1109). Engine only: no evaluation or
design-format change.

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

* Timeline: a body made while a feature is suppressed keeps its name (#4302)

Suppressing a feature freed its counted name (`Body1`), so a body made
meanwhile took it and became `Body3` once the feature was back. Suppress
now also stores the suppressed feature's own counted names, and the
evaluator adds the names stored on earlier suppressed features to the
state's `reserved_body_names` (the names a rename freed, #4957), which the
`BodyN` counter passes by. They are taken from the design before each
feature is computed and are part of the state digest, so a cached result
is only reused when they match.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* cadmpeg 0.6.3: FreeCAD and Rhino import in the browser too (#4482)

cadmpeg 0.6.3 is pure Rust (ruzstd instead of the zstd C library, zip 9), so
solidcraft-io depends on it on every target and the wasm build reads .FCStd
and .3dm files like the desktop app. The browser's file pickers offer them.

0.6.3 replaces DecodeOptions::container_only with a decode depth; ask for
Geometry, what its STEP writer reads. zip 8 leaves the tree, and with it the
three cargo-deny advisory exceptions kept for it.

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

* Pattern dialogs: Quantity fields get up/down step arrows (#4260) (#4264)

A new kit control, `ui_kit::count_field` / `count_row`, is an expression field for a
whole-number count with stacked step arrows at its right edge. An arrow steps the
evaluated count by one within the field's limits: a plain number is replaced, an
expression keeps its parameters and gains or adjusts a trailing `+ n`. The text
changes as typing changes it, so the live preview follows.

Used by the Quantity rows of the sketch Rectangular (1-500, both directions together
at most 500) and Circular (2-500) patterns, and the solid Rectangular, Circular and
Path patterns (1-1000).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* Join: say so when the joined piece doesn't touch the body (#4434) (#4436)

* claim: #4434

* Join: say so when the joined piece doesn't touch the body (#4434)

A Join (extrude, revolve, sweep, loft, pipe, coil, primitives, thicken,
boundary fill, surface network) whose material meets none of its targets
still joins it to the first target as a separate piece. The result stays
as it was; the feature now warns: "the joined piece doesn't touch `Body1`,
so `Body1` is now in 2 separate pieces (New Body makes it a body of its
own)". History marks it and the command reply carries it.

The check compares pieces (outer shells, not voids) of the bodies the
feature changed or absorbed before and after it, so it holds for the
batched multi-profile path too, stays quiet for a join that bridges
pieces or closes a cavity, and leaves Combine (which joins the bodies it
is given) alone.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Hole: snap to face and round-edge centres, drag placed holes, section in the dialog (#2171) (#2912)

Placing a Single hole put it exactly where the click landed, with nothing to line it up by and no
way to move it but taking it out and picking again; the dialog was a list of size fields.

- Preferences › Design › Create Hole has two settings, both off by default, so a new installation
  places holes as before:
  - Snap to face and edge centres (`hole_snaps`): the planar face under the cursor offers its
    middle and the centres of its round edges (a boss, a rounded corner, a hole already there) as
    marks; a pick near one, or on or beside such an edge, takes that centre and the mark is named.
    Sketch points on the face still come first; Alt or Ctrl held places it where clicked.
  - Move holes by clicking or dragging (`hole_moves`): each placed hole has a handle that drags it
    (snapping when snaps are on) and takes it out when clicked, and a pick elsewhere on its face
    moves it there. Edit Feature shows the handle too, so a hole can be moved later (#1047).
- The Hole dialog draws a section of the hole as its options make it, with each size in a field
  beside the dimension it sets; a hovered or focused field lights its dimension. The layout is a
  new kit element, `ui_kit::figure_rows` (a caller-painted figure beside captioned fields).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Hole: place a Single hole by distances from reference edges (#37) (#2931)

A Single hole can take up to two straight edges of its face as references,
each with the distance of the hole's centre from that edge's line, measured
in the face's plane. `solid.hole` takes them as `references: [{edge, distance}]`;
the edges are found again by their persistent names, else their points, so
the hole follows them when the part changes, and each distance is a
parameter. A bad reference (no edge there, a curved edge, one square to the
face, parallel edges, a negative distance, a centre off the face) is refused.

In the Hole dialog, Reference 1 and 2 follow the face, each with its Distance
row; the canvas shows each distance as a dimension from its edge to the hole,
with a box just past the edge to type it in. Picking an edge measures its
distance (and its box takes the keyboard), typing one moves the hole, and
moving the hole measures them again. Edit Feature picks the edges again with
their distances.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Karl Von Richter<karl@embeint.com>
Co-authored-by: Frank <58932224+plexi32@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: raykholo <raykholo@users.noreply.github.com>
Co-authored-by: CamWight <91156642+CamWight@users.noreply.github.com>
Co-authored-by: SS-EN <84559909+SS-EN@users.noreply.github.com>
Co-authored-by: Colin Faulkingham <colin.faulkingham@gmail.com>
Co-authored-by: Karl Von Richter <karl@von-richter.net>
Co-authored-by: HiroKenEz <59478073+HiroKenEz@users.noreply.github.com>
Co-authored-by: Peppe Bergqvist <p@bergqvi.st>
Co-authored-by: GitHub Copilot <noreply@github.com>
Co-authored-by: phanoob <lesur.Stephane@wanadoo.fr>
Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Kai Evans <nemshilov@gmail.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Tyler Chiriboga <tylerchiriboga.apps@gmail.com>
Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: CaRLymx <carlymx@gmail.com>
Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
Co-authored-by: Loohi <78334654+Loohi@users.noreply.github.com>
Co-authored-by: leValdemar19 <valdemarnord@hotmail.com>
Co-authored-by: AhmetV <mihendiz10@gmail.com>
Co-authored-by: AhmetV <344941595+ahmetv10@users.noreply.github.com>
Co-authored-by: rlartot <94693724+rlartot@users.noreply.github.com>
Co-authored-by: Christian Svensson <blue@cmd.nu>
Co-authored-by: Thodoris Tsagkogeorgas <thodoristsagko@gmail.com>
Co-authored-by: ahmetalpcelik <80778880+ahmetalpcelik@users.noreply.github.com>
Co-authored-by: ealitt <ealitt@gmail.com>
Co-authored-by: Homero Thompson <vdirienzo@gmail.com>
Co-authored-by: Diogo Soares Rodrigues <dsrodrigues20@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Artem Sushchev <Suschev.Artem+github@gmail.com>
Co-authored-by: Nathan Dane <nathandane.dev@gmail.com>
Co-authored-by: Kai Evans <hi@kaievans.co>
Co-authored-by: Victor Santos Oliveira <56417388+Vittuu@users.noreply.github.com>
Co-authored-by: cronos <crono2250@users.noreply.github.com>
Co-authored-by: rbousigues88 <rbousigues@gmail.com>
Co-authored-by: The Nomad <nomad@positron3d.com>
Co-authored-by: Jackson Oursland <jacob.oursland@gmail.com>
Co-authored-by: Jacques <59315319+Jacquesvdberg92@users.noreply.github.com>
2026-10-24 02:09:20 -04:00
selune 0b3e9a9e1e fix(render): wrap complete backend diagnostics (#5714) (#5715)
Preserve full backend/job/decoded-image diagnostics in the existing shared panel without ellipsis. The painted-galley regression covers Floating/Compact and light/dark, complete text, wrapping and fixed panel width. Exact-tree Render UI 33/0, strict UI Clippy, WASM and repository gates pass. Real safe-mode window captures, camera restore, CPU export and normal exit are recorded; no backend behavior or unrelated UI policy changes.
2026-10-24 08:05:00 +02:00
selune 24c9d14663 GPU: adapt dispatches and retire lost devices (#5754) (#5755)
* claim: #5754

* fix(gpu): adapt dispatch size and retire lost path devices (#5754)

Start with a 64-item quantum and adapt bounded work toward 20 ms from observed completion, including denoiser tiles. Never exceed 16384 work items; hardware-specific shader cost means this is not a universal TDR guarantee. Publish device loss/uncaptured failure/poll failure through live executor availability without reinitializing automatically. Source-pinned required-Vulkan GPU suite: 105 passed, zero failed, three existing ignores, including actual device.destroy retirement and session availability. Strict GPU/engine all-target Clippy passes. Broader engine and repository/WASM continuation remains separately recorded. No geometry baseline, CI rule or published format history change.
2026-10-24 08:04:52 +02:00
selune 934d4ddae2 Render: defer path-host startup until first use (#5742) (#5743)
* claim: #5742

* fix(render): defer GPU path initialization until first use (#5742)

Retain the selected adapter factory during Design/safe-mode startup; instantiate once on first Render entry, including late viewport installation. Preserve the deferred/preparing/ready/failed state across document changes and retain selected fallback adapter policy. Exact tree passes 32 render-workspace and 1966 full UI tests (0 failures; 7 existing ignored), strict native all-target Clippy, wasm check and repository gates. Three wasm warnings remain in untouched upstream files. Fresh native desktop acceptance is separate and in progress; no whole-renderer/main sign-off implied.
2026-10-24 07:25:50 +02:00
selune 7a81a7fc15 library: transport PBR and engineering cards without lossy copies (#1086)
* wip(library): reconcile exact rich-catalog replacement semantics (#1062)

Source-only scoped extraction from original 0f801962 / #1086 on the refreshed #1060 card prerequisite. Preserve current asynchronous picker guards and source-ID handles. Scalar v1 and rich-v2-without-PBR clear existing PBR; supplied rich PBR is retained. No new saved concept or duplicate card descriptor. Formatting and source review completed; compile/native acceptance is still pending.

* test(library): preserve explicit downgrade and fallible UI regressions (#1062)

Request scalar v1 explicitly in the card downgrade refusal regression; rich v2 remains the default lossless writer. Propagate fallible harness actions with ? in the shared clicked-catalog helper, preserving all assertions. No production code, limits or ignore changes. Fresh grouped IO/engine/UI tests, scoped strict Clippy and WASM pass on this exact tree.
2026-10-24 07:13:19 +02:00
selune 04acdc251f GPU: preserve error scopes on cancelled preparation (#5722) (#5723)
* claim: #5722

* fix(gpu): unwind preparation error scopes on cancellation (#5722)

Move fallible CPU HDR packing before GPU scopes and drain the remaining scope stack in reverse order before propagating denoiser/resource allocation failures. Native regressions cover cancellation and allocation-error cleanup plus subsequent scope reuse. Exact candidate Vulkan-required GPU suite: 101 passed, 0 failed, 3 existing ignored; strict GPU all-target Clippy and repository assets/layers/lists/container checks pass. No driver exception, shader transport or test limit is suppressed.
2026-10-24 01:04:07 +02:00
selune 8cef71d2d2 Merge pinned main ancestry after #5705
The validated content already landed by squash. Restore the normal main-to-render ancestry requested in #5493 without changing its tree.
2026-10-23 23:35:47 +02:00
selune 47a1459060 Sync current main into render, preserving release and viewport changes (#5705)
* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* Viewport: whole-body highlights reuse the model's own triangles (#4554) (#5109)

Hovering or selecting whole bodies -- a browser row, a History group, a body
under the pointer in body-pick mode -- built a CPU copy of every body's
triangles into the highlight scene. On a 40-body 4.3 MB STEP assembly that was
147 bodies, 70 MB of triangle copies and a 519 ms frame.

The model already has the same geometry on the GPU. The highlight now names it
and the renderer draws it again in the highlight colour:

- bodies drawn from a display plan: the model's evaluated plan chunks, with a
  hover/selection tint uniform (the chunks stay resident in the model scene);
- bodies drawn as placements of shared triangles: the same placements of the
  model's shared triangles, coloured per instance;
- bodies the model builds on the CPU: their vertex range in the model scene's
  block.

See-through and faded bodies keep the CPU feedback, which colours them; faces
and edges highlighted on their own are unchanged. The highlight looks the same
(verified side by side).

On the assembly the highlight build drops from 519 ms to 9 ms and no longer
copies any triangles; selecting the whole assembly takes 9-20 ms.

Co-authored-by: Claude Code <noreply@anthropic.com>

* Offer to stand up SolidWorks files instead of turning them silently; add Turn Up Axis to the component menu (#5655)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Sketch on a picked origin plane uses the plane shown after standing an import up (#5659)

* Sketch on a picked origin plane uses the plane shown, also in a moved component

After a STEP file opens its component stays the active one, and standing it up
turns that component a quarter about X. Starting a sketch on XY picked in the
viewport then sent `{"plane": "XY"}`, which names the active component's own
XY: upright after the stand-up. The sketch landed on the world XZ, Look At
turned to face Y and the extrude went along Y.

The viewport and browser only show the design's origin planes, so a picked
origin plane now goes to sketch.create written out as that world plane while
the active component is moved; sketch.create maps it into the component's
frame once. The flip toward the viewer uses the same plane.

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

* Stand-up axes tests: stand the file up through the offer when one is waiting

#5655 offers to stand up SolidWorks files instead of turning them silently.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* gpu tests: give the two test frames the new hover and selection tints (#5698)

#5109 added Frame::hover_tint and selection_tint; two test frames that landed alongside it didn't set them, so solidcraft-gpu's tests stopped compiling on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Release 0.21.0-rc.2 (#5691)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jackson Oursland <jacob.oursland@gmail.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-23 17:29:50 -04:00
selune ee5ca2430b feat(render): expose independent anisotropic GGX core (#1694) (#1699)
Extract the original numerical source and analytic tests from published #1698 at 463a3e0d into its existing #1699 constituent. Keep current GPU/shared jobs, format and kernel untouched. Add scoped numerical-contract documentation. Full render library 158 passed, strict scoped Clippy and wasm pass, as do assets/layers/lists/format; source tree pinned in the acceptance record. No runtime consumer or final combined renderer acceptance is claimed.
2026-10-23 23:06:31 +02:00
selune ac08c3f106 Merge pinned main into render (#5669)
Normal history-preserving main-to-render synchronization under #5493. Retain both material defaults and engineering-card bounds; initialize the new upstream test fixture with no optional card. The corrected source passed fresh quick checks. No rewrite of main/render, manual document format bump or final main promotion is performed.
2026-10-23 22:10:14 +02:00
0xlillith 6a197b6cf6 test(material): initialize optional cards in upstream density regression
Fix the E0063 found by quick run 40015318093 while combining main #4785 with render #1060. The new upstream fixture explicitly has no engineering card. Keep every density/name/default-material assertion and production implementation unchanged.
2026-10-23 22:06:32 +02:00
0xlillith e3d7cbe830 Merge pinned main into the shared render integration
Synchronize official main 17bac141c8 into render e3c9f22ead under #5493. Resolve only the independent DEFAULT_MATERIAL / MAX_USER_MATERIALS imports, keeping both rules. Preserve all main and renderer history. No manual document format bump or released migration edit; release folding is inherited from main. Fresh quick checks are required before this normal merge reaches render.
2026-10-23 21:56:13 +02:00
Christian Svensson e3c9f22ead GPU path tracing: use compatible Vulkan adapters (#3306)
Land the final Vulkan-only path-executor policy after #2191 under the #5493 constituent route. Fixes #3217. Fresh quick run 40012565783 passed. Preserve adapter/software policy and CPU fallback; no DXC dependency or workflow changes. Full native/hardware acceptance and final jyviko sign-off remain separate.

Co-Authored-By: Christian Svensson <blue@cmd.nu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:40:28 +02:00
Christian Svensson c6cbde3706 Render: studio lighting, clear coat and glass on GPU (#2191)
Integrate the original studio renderer after the GPU executor and async UI foundations, under #5493. Fresh render-target quick run 40011408339 passes. Preserve attribution, bounded resource admission and documented glass/denoising limits. Historical native benchmarks retain their source pins. Full renderer-to-main CI and jyviko sign-off remain separate.

Co-Authored-By: Christian Svensson <blue@cmd.nu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-10-23 21:33:49 +02:00
Christian Svensson b0d7572daf Render: asynchronous GPU setup and completed-image panel (#2780)
Integrate the original asynchronous setup, progress and completed-image UI after #2067. Refreshed source passes quick gate 40009901424. Original attribution retained. Final render-to-main full CI and jyviko sign-off remain separate.

Co-Authored-By: Christian Svensson <blue@cmd.nu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:24:40 +02:00
Christian Svensson 30449418ac Render: progressive GPU path-tracing executor (#2067)
Land the unchanged contributor GPU foundation into render under the maintainer policy in #5493. Fresh quick gate 39993339605 passed. Original author and co-author attribution retained; no workflow file changes. Final render-to-main CI and jyviko sign-off remain separate.

Co-Authored-By: Christian Svensson <blue@cmd.nu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:10:55 +02:00
selune 95a56ca00e Persist validated engineering cards independently of mass and optics (#1060)
Render constituent under #5493. Fixes #1048. One pending material-engineering-cards descriptor, immutable released migrations, independent assigned density and source conditions. Nine persistence and ten physical-card regressions plus the fresh quick gate pass. Additional native follow-up is reported separately; no full-stack acceptance is implied.
2026-10-23 20:49:32 +02:00
selune fbf4a5458d Preserve translated source-label aliases (#5619)
Render integration prerequisite for #5493. Fixes #5618. Retain stable-ID fallback and translated legacy captions; include controlled regression coverage.
2026-10-23 20:06:26 +02:00
selune 2f8677a640 Merge main into render for constituent reconciliation (#5493) 2026-10-23 19:46:29 +02:00
selune bf69db04dd Normalize UI container violation paths across platforms (#5599)
Report repository-relative paths with forward slashes so diagnostics and the existing nested-package regression assertions agree on Windows and Unix. Keep the container detection rules unchanged.

Validation: official ui-containers gate and all seven focused xtask tests passed; rustfmt and diff checks passed. Only xtask/dependencies were compiled; no application build or heavy tests.

(cherry picked from commit 44b58d8e10)

Scoped render-branch prerequisite from #4507, coordinated under #5493. No unrelated translation/kernel changes are included. Fresh Windows fmt, seven ui_containers regressions and the production container gate pass on render base 3a6153d18f. Full CI is reserved for final render-to-main acceptance under the maintainer policy.

Co-authored-by: Victor Santos Oliveira <56417388+Vittuu@users.noreply.github.com>
2026-10-23 13:21:05 -04:00
selune e9f2f251e4 Merge current main into render per #5493 integration policy 2026-10-23 19:18:26 +02:00
154 changed files with 48446 additions and 290 deletions
+10
View File
@@ -70,6 +70,16 @@ filter = """
"""
priority = 100
# Each nextest test has its own process/device. Concurrent cold lavapipe path
# compiles contend for CPU and can exhaust the 600 s pipeline setup budget.
# Serialize this module per CI shard; other tests retain their normal parallelism.
[test-groups.gpu-path]
max-threads = 1
[[profile.ci.overrides]]
filter = "package(=solidcraft-gpu) & test(/^path::/)"
test-group = "gpu-path"
# Full local validation opts into its own scheduling policy. Keep GitHub CI's
# worker reservation and slow-test priorities above independent of this runner.
[profile.local-ci]
+91
View File
@@ -15,8 +15,20 @@ Editable drawing code, native projects and shipped exports are collected under `
| File | Author | Source | Licence |
|---|---|---|---|
| examples/chebyshev-motion-trace.json | SolidCraft contributors | Original parametric Chebyshev lambda mechanism and associative-trace demo, constructed from analytic circle intersections; classical link proportions documented at https://doi.org/10.1002/rcs.70095; no external CAD geometry or artwork | MIT OR Apache-2.0 |
| examples/material-card.json | SolidCraft contributors | original analytic box and explicitly synthetic engineering card; invented illustrative constants, no external material dataset | MIT OR Apache-2.0 |
| examples/studio/author_sweep.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/author_scene.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/materials.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded-sweep.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded-render.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/stage1-sweep.json | SolidCraft contributors | original tangent circular-arc CAD cyclorama; no external assets | MIT OR Apache-2.0 |
| examples/studio/stage1.json | SolidCraft contributors | original editable CAD studio scene; no imported geometry or external image assets | MIT OR Apache-2.0 |
| examples/studio/stage1-render.json | SolidCraft contributors | original studio camera and lighting parameters | MIT OR Apache-2.0 |
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
| examples/render-path-studio.json | SolidCraft contributors | original procedural opaque enclosure, teal insert and metal fasteners for progressive rendering; no imported models | MIT OR Apache-2.0 |
| crates/gpu/src/path/path.wgsl | SolidCraft contributors | original software BVH traversal and path integration using the existing SolidCraft surface/studio equations; no external shader source | MIT OR Apache-2.0 |
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
@@ -98,6 +110,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/appearances/starter_pack.rs | SolidCraft contributors | original illustrative optical recipes, authored scalar values and future procedural resource descriptors; no measured or external material dataset | MIT OR Apache-2.0 |
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
@@ -546,6 +559,12 @@ copied into SolidCraft.
| Directory | Source | Licence | Changes |
|---|---|---|---|
| examples/studio/author_sweep.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/author_scene.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/materials.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded-sweep.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded-render.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
@@ -564,3 +583,75 @@ A frame whose meshes the GPU refuses to stage (device lost or out of memory) is
skipped with a warning instead of panicking (#1469).
Paint callbacks are prepared only after a drawable is acquired, so a skipped
surface frame cannot strand cached meshes without their GPU initialization.
## Runtime studio lighting mathematics
`crates/gpu/src/path/path.wgsl` implements the power-heuristic estimator from the
mathematical description in [PBRT fourth edition, section 13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer).
No PBRT code or assets were copied. `crates/render/src/job/lighting.rs` evaluates
[Krzysztof Narkowicz's published 2016 ACES rational fit](https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/)
using its published coefficients (author licenses the fit under CC0/MIT).
These original implementations are under SolidCraft's MIT OR Apache-2.0 licence.
## Original coffee framing variant
| File | Author | Provenance | Licence |
|---|---|---|---|
| examples/studio/author_coffee_framing.py | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-framing.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-framing-sweep.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-framing-render.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-framing.md | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
## Original cylindrical coffee housing variant
| File | Author | Provenance | Licence |
|---|---|---|---|
| examples/studio/author_coffee_cylinder.py | SolidCraft contributors | original parametric cylindrical CAD construction and editable command recipes; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-cylinder.json | SolidCraft contributors | original cylindrical housing on the original exploded coffee composition; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-cylinder-sweep.json | SolidCraft contributors | original cylindrical housing and continuous CAD cyclorama; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-cylinder-render.json | SolidCraft contributors | original camera, studio lighting and optical settings retained from the framing variant | MIT OR Apache-2.0 |
| examples/studio/coffee-cylinder.md | SolidCraft contributors | original dimensional recipe, provenance and validation limitations | MIT OR Apache-2.0 |
## Runtime environment sampling
`crates/render/src/lighting/environment.rs` and the environment/sun portions of
`crates/gpu/src/path/path.wgsl` are original SolidCraft implementations of
solid-angle importance sampling and MIS, informed by the mathematical description
in [PBRT fourth edition, sections 12.5](https://pbr-book.org/4ed/Light_Sources/Infinite_Area_Lights)
and [13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer).
The procedural gradient and inline RGBE test fixtures are original mathematical
colours, not downloaded imagery. No third-party environment images or shader code
are included. Licence: MIT OR Apache-2.0.
## Original tumbler showcase assets
The CAD room, tumblers, window opening, wood/weave patterns and RGBE sky below
are original SolidCraft contributor work. No third-party scene, photograph, HDRI,
texture, or AI-generated artwork was used. Python authoring sources are included.
All listed files use the repository MIT OR Apache-2.0 licence.
| File | Source / author | Use | License |
| --- | --- | --- | --- |
| examples/studio/author_showcase.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/author_tumblers.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/textures/ATTRIBUTION.md | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/textures/author_textures.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/textures/environment-manifest.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/textures/manifest.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-hdr-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-hdr-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-materials.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-sun-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-sun-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-sun-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-sun.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
The original studio generators produce local ignored PNG maps and an RGBE gradient.
Generated image bytes are not source assets; their authorship, licences and expected
hashes are documented under `examples/studio/textures`. Rendered evidence belongs
on the results branch. No photographs, scans or external image assets are included.
Generated
+2
View File
@@ -4312,9 +4312,11 @@ dependencies = [
name = "solidcraft-gpu"
version = "0.21.0"
dependencies = [
"image",
"log",
"naga",
"pollster 0.4.0",
"robust",
"serde",
"serde_json",
"solidcraft-engine",
+6
View File
@@ -143,6 +143,12 @@ completed drawings publish atomically. The browser still exports synchronously.
## Render foundation
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
- Explicit texture normalization (#1218) produces completed float RGBA with
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
@@ -0,0 +1,102 @@
//! Reproducible evidence through the production command/job interfaces.
//! Usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png
//! Writes the editable design, command timings and actual adapter/job metadata beside PNG.
use serde_json::{Value, json};
use solidcraft_engine::{
Session,
render::job::{CpuHost, ErrorCode, JobId, Phase},
};
use std::{
io::Read,
path::Path,
time::{Duration, Instant},
};
fn read_json(path: &str) -> Result<Value, String> {
let mut bytes = Vec::new();
std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?.take(16_777_217).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
if bytes.len() > 16_777_216 {
return Err("evidence input exceeds 16 MiB".into());
}
serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))
}
fn run() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [recipe_path, params_path, output_path] = args.as_slice() else {
return Err("usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png".into());
};
let output = Path::new(output_path);
if !output.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")) {
return Err("output must have a .png extension".into());
}
let recipe = read_json(recipe_path)?;
let params = read_json(params_path)?;
let mut session = Session::default();
eprintln!("Generating original CAD scene from {recipe_path}");
let generation_start = Instant::now();
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
let generation_seconds = generation_start.elapsed().as_secs_f64();
let design_path = output.with_extension("solidcraft");
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
let scene = solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?;
let bodies: Vec<Value> = scene
.meshes
.iter()
.map(|mesh| json!({"name":mesh.name,"vertices":mesh.mesh.positions.len(),"triangles":mesh.mesh.triangles.len()}))
.collect();
drop(scene);
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, adapter_report) = solidcraft_gpu::select::pick(policy).ok_or("no adapter satisfies the requested GPU policy")?;
let host_start = Instant::now();
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let host_seconds = host_start.elapsed().as_secs_f64();
let preflight = session.execute("render.preflight", &params).map_err(|e| e.to_string())?;
let start = Instant::now();
let started = session.execute("render.start", &params).map_err(|e| e.to_string())?;
let id: JobId = serde_json::from_value(started.get("job").cloned().ok_or("missing job id")?).map_err(|e| e.to_string())?;
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
eprintln!("Rendering through production gpu-path host: {adapter_report:?}");
let status = loop {
match handle.wait_timeout(Duration::from_secs(10)) {
Ok(status) => break status,
Err(error) if error.code == ErrorCode::TimedOut && start.elapsed() < Duration::from_secs(3600) => {
let status = handle.status().map_err(|e| e.to_string())?;
eprintln!(
"Render progress: {:?} {}/{} after {:.1}s",
status.progress.phase,
status.progress.done,
status.progress.total,
start.elapsed().as_secs_f64()
);
}
Err(error) => {
let _ = handle.cancel();
return Err(error.to_string());
}
}
};
let render_and_encode_seconds = start.elapsed().as_secs_f64();
if status.progress.phase != Phase::Completed {
return Err(status.error.map_or_else(|| "render did not complete".into(), |e| e.to_string()));
}
let exported = session.execute("render.export", &json!({"job":id,"path":output_path,"overwrite":true})).map_err(|e| e.to_string())?;
let report = json!({
"recipe":recipe_path,"render_params":params,"adapter":adapter_report,
"environment":{"SOLIDCRAFT_GPU":std::env::var("SOLIDCRAFT_GPU").ok(),"WGPU_BACKEND":std::env::var("WGPU_BACKEND").ok()},
"cad_generation_seconds":generation_seconds,"gpu_host_setup_seconds":host_seconds,
"render_and_encode_seconds":render_and_encode_seconds,
"timing_scope":"wall time from render.start through completed PNG encoding; includes job preparation and readback, excludes CAD generation and initial GPU pipeline setup",
"bodies":bodies,"commands":commands,"command_timings":command_timings,
"preflight":preflight,"export":exported,"editable_design":design_path
});
std::fs::write(output.with_extension("json"), serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
println!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
Ok(())
}
fn main() -> Result<(), String> {
run()
}
+1 -1
View File
@@ -69,7 +69,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.solidcraft|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]\n solidcraft-cli snapshot --in <design.solidcraft|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";
/// `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";
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";
/// `exec`'s lines in the usage.
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
/// `commands`'s lines in the usage.
+38 -2
View File
@@ -11,15 +11,25 @@ pub fn run(args: &[String]) -> Result<(), String> {
let mut setup = None;
let mut width = None;
let mut height = None;
let mut progressive = false;
let mut samples = None;
let mut max_depth = None;
let mut seed = None;
let mut exposure = None;
let mut lighting = None;
let mut tone_map = None;
let mut environment = None;
let mut transparent = false;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
while let Some(arg) = args.get(i) {
match arg.as_str() {
"--progressive" => progressive = true,
"--transparent" => transparent = true,
"--overwrite" => overwrite = true,
"--out" | "--setup" | "--width" | "--height" | "--timeout" => {
"--out" | "--setup" | "--width" | "--height" | "--timeout" | "--samples" | "--max-depth" | "--seed" | "--exposure" | "--lighting"
| "--tone-map" | "--environment" => {
i += 1;
let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?;
match arg.as_str() {
@@ -27,6 +37,26 @@ pub fn run(args: &[String]) -> Result<(), String> {
"--setup" => setup = Some(value.clone()),
"--width" => width = Some(value.parse::<u32>().map_err(|_| "invalid width")?),
"--height" => height = Some(value.parse::<u32>().map_err(|_| "invalid height")?),
"--samples" => samples = Some(value.parse::<u32>().map_err(|_| "invalid samples")?),
"--max-depth" => max_depth = Some(value.parse::<u32>().map_err(|_| "invalid depth")?),
"--seed" => seed = Some(value.parse::<u32>().map_err(|_| "invalid seed")?),
"--exposure" => exposure = Some(value.parse::<f64>().map_err(|_| "invalid exposure")?),
"--environment" => {
let text =
solidcraft_engine::io::vfs::read_to_string(value, 65536).map_err(|e| format!("could not read environment: {e}"))?;
environment = Some(serde_json::from_str::<serde_json::Value>(&text).map_err(|e| e.to_string())?);
}
"--lighting" => {
let text = solidcraft_engine::io::vfs::read_to_string(value, 65536).map_err(|e| format!("could not read lighting: {e}"))?;
lighting = Some(serde_json::from_str::<solidcraft_engine::render::job::StudioLighting>(&text).map_err(|e| e.to_string())?);
}
"--tone-map" => {
tone_map = Some(match value.as_str() {
"reinhard" => solidcraft_engine::render::job::ToneMap::Reinhard,
"aces_fitted" => solidcraft_engine::render::job::ToneMap::AcesFitted,
_ => return Err("tone-map must be reinhard or aces_fitted".into()),
})
}
_ => timeout = value.parse::<u64>().map_err(|_| "invalid timeout")?,
}
}
@@ -40,6 +70,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
let out = out.ok_or("render requires --out FILE.png")?;
let mut session = crate::load(input)?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
if progressive {
session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
}
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
let mut settings = if let Some(name) = setup {
session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")?
@@ -58,7 +91,10 @@ pub fn run(args: &[String]) -> Result<(), String> {
if let Some(height) = height {
settings.height = height;
}
let params = json!({"settings":settings,"transparent_background":transparent});
if let Some(exposure) = exposure {
settings.exposure_ev = exposure;
}
let params = json!({"settings":settings,"transparent_background":transparent,"progressive":progressive,"samples":samples,"max_depth":max_depth,"seed":seed,"lighting":lighting,"tone_map":tone_map,"environment":environment});
let preflight = session.execute("render.preflight", &params).map_err(|e| e.to_string())?;
let started = session.execute("render.start", &params).map_err(|e| e.to_string())?;
let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?;
+13
View File
@@ -144,3 +144,16 @@ fn fuzz_refuses_an_argument_it_does_not_take() {
assert!(stderr.contains(arg), "`fuzz {arg}`: {stderr}");
}
}
#[test]
fn render_help_keeps_the_registered_gpu_request_controls() {
for args in [vec!["--help"], vec!["render", "--help"]] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(&args).output().unwrap();
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"] {
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
}
}
}
+292
View File
@@ -0,0 +1,292 @@
//! Original starter visual data, MIT OR Apache-2.0. No external material library.
use crate::appearance_pack::{Category, GlassBoundary, GlassRecipe, OpaqueRecipe, TextureAxis, TextureRecipe, VisualPreset};
const WOOD: &str = "Uniform opaque preview only; grain resources/mapping are not applied";
const SOLAR: &str = "Uniform opaque preview only; no cell grid, busbars, electricity or efficiency claim";
const REGOLITH: &str = "Uniform opaque preview only; no granular displacement or measured lunar optical data";
const OPAQUE: &str = "Illustrative opaque isotropic PBR; no measured alloy, polymer or engineering properties";
const fn look(color: [u8; 3], metallic: f64, roughness: f64, dielectric_ior: f64) -> OpaqueRecipe {
OpaqueRecipe { color, metallic, roughness, dielectric_ior }
}
const fn maps(id: &'static str, repeat_mm: [f64; 2], grain: bool) -> Option<TextureRecipe> {
Some(TextureRecipe { id, repeat_mm, grain_axis: if grain { Some(TextureAxis::V) } else { None } })
}
const fn glass(transmission_linear_rgb: [f64; 3], ior: f64, reference_path_mm: f64, boundary: GlassBoundary) -> GlassRecipe {
GlassRecipe { transmission_linear_rgb, dielectric_ior: ior, reference_path_mm, boundary }
}
pub const STARTER_PACK: [VisualPreset; 40] = [
VisualPreset::opaque(
"starter.wood.oak-honey",
"Wood - Honey Oak Uniform Preview",
Category::Wood,
look([190, 145, 88], 0.0, 0.64, 1.5),
WOOD,
maps("starter.wood.oak-honey.maps-v1", [120.0, 240.0], true),
),
VisualPreset::opaque(
"starter.wood.oak-smoked",
"Wood - Smoked Oak Uniform Preview",
Category::Wood,
look([91, 72, 55], 0.0, 0.68, 1.5),
WOOD,
maps("starter.wood.oak-smoked.maps-v1", [120.0, 240.0], true),
),
VisualPreset::opaque(
"starter.wood.walnut-dark",
"Wood - Dark Walnut Uniform Preview",
Category::Wood,
look([99, 66, 43], 0.0, 0.60, 1.5),
WOOD,
maps("starter.wood.walnut-dark.maps-v1", [100.0, 220.0], true),
),
VisualPreset::opaque(
"starter.wood.maple-light",
"Wood - Light Maple Uniform Preview",
Category::Wood,
look([221, 192, 141], 0.0, 0.57, 1.5),
WOOD,
maps("starter.wood.maple-light.maps-v1", [100.0, 200.0], true),
),
VisualPreset::opaque(
"starter.wood.cherry-warm",
"Wood - Warm Cherry Uniform Preview",
Category::Wood,
look([164, 99, 65], 0.0, 0.58, 1.5),
WOOD,
maps("starter.wood.cherry-warm.maps-v1", [110.0, 220.0], true),
),
VisualPreset::opaque(
"starter.wood.ash-pale",
"Wood - Pale Ash Uniform Preview",
Category::Wood,
look([209, 186, 146], 0.0, 0.70, 1.5),
WOOD,
maps("starter.wood.ash-pale.maps-v1", [120.0, 260.0], true),
),
VisualPreset::opaque(
"starter.wood.birch-cream",
"Wood - Cream Birch Uniform Preview",
Category::Wood,
look([228, 206, 163], 0.0, 0.63, 1.5),
WOOD,
maps("starter.wood.birch-cream.maps-v1", [100.0, 200.0], true),
),
VisualPreset::opaque(
"starter.wood.teak-golden",
"Wood - Golden Teak Uniform Preview",
Category::Wood,
look([167, 122, 66], 0.0, 0.61, 1.5),
WOOD,
maps("starter.wood.teak-golden.maps-v1", [110.0, 250.0], true),
),
VisualPreset::opaque(
"starter.metal.carbon-steel-polished",
"Metal - Polished Carbon Steel",
Category::Metal,
look([161, 166, 173], 1.0, 0.16, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.stainless-soft-satin",
"Metal - Soft Satin Stainless",
Category::Metal,
look([187, 193, 201], 1.0, 0.36, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.aluminum-bead-blast",
"Metal - Bead Blast Aluminum",
Category::Metal,
look([195, 201, 210], 1.0, 0.58, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.aluminum-silver-polish",
"Metal - Silver Polish Aluminum",
Category::Metal,
look([218, 224, 232], 1.0, 0.13, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.copper-aged-warm",
"Metal - Warm Aged Copper",
Category::Metal,
look([171, 95, 57], 1.0, 0.48, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.brass-champagne",
"Metal - Champagne Satin Brass",
Category::Metal,
look([205, 173, 102], 1.0, 0.32, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.bronze-dark-cast",
"Metal - Dark Cast Bronze",
Category::Metal,
look([133, 95, 58], 1.0, 0.66, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.titanium-matte",
"Metal - Matte Gray Titanium",
Category::Metal,
look([147, 149, 157], 1.0, 0.53, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.gold-warm",
"Metal - Warm Reflective Gold",
Category::Metal,
look([235, 190, 82], 1.0, 0.18, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.silver-cool",
"Metal - Cool Reflective Silver",
Category::Metal,
look([225, 230, 237], 1.0, 0.12, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.abs-charcoal",
"Plastic - Charcoal ABS",
Category::Plastic,
look([43, 45, 48], 0.0, 0.44, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.abs-warm-white",
"Plastic - Warm White ABS",
Category::Plastic,
look([232, 229, 220], 0.0, 0.40, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque("starter.plastic.pla-coral", "Plastic - Coral PLA", Category::Plastic, look([218, 99, 83], 0.0, 0.43, 1.5), OPAQUE, None),
VisualPreset::opaque(
"starter.plastic.petg-cyan-opaque",
"Plastic - Opaque Cyan PETG",
Category::Plastic,
look([36, 148, 167], 0.0, 0.29, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.nylon-ivory",
"Plastic - Ivory Nylon",
Category::Plastic,
look([220, 213, 190], 0.0, 0.62, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.pc-graphite-opaque",
"Plastic - Opaque Graphite PC",
Category::Plastic,
look([70, 76, 84], 0.0, 0.27, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.hdpe-natural-opaque",
"Plastic - Opaque Natural HDPE",
Category::Plastic,
look([221, 222, 207], 0.0, 0.48, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque("starter.plastic.pom-black", "Plastic - Black POM", Category::Plastic, look([29, 31, 35], 0.0, 0.32, 1.5), OPAQUE, None),
VisualPreset::opaque(
"starter.plastic.tpu-matte-black",
"Plastic - Matte Black TPU",
Category::Plastic,
look([31, 30, 32], 0.0, 0.83, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.peek-amber-opaque",
"Plastic - Opaque Amber PEEK",
Category::Plastic,
look([157, 119, 62], 0.0, 0.51, 1.5),
OPAQUE,
None,
),
VisualPreset::glass(
"starter.glass.clear-soda-lime",
"Glass Recipe - Clear Soda Lime",
glass([0.92, 0.98, 0.96], 1.52, 3.0, GlassBoundary::Closed),
),
VisualPreset::glass("starter.glass.low-iron-clear", "Glass Recipe - Low Iron Clear", glass([0.98, 0.99, 1.0], 1.50, 4.0, GlassBoundary::Closed)),
VisualPreset::glass("starter.glass.smoky-neutral", "Glass Recipe - Smoky Neutral", glass([0.35, 0.38, 0.42], 1.52, 3.0, GlassBoundary::Closed)),
VisualPreset::glass("starter.glass.amber-tint", "Glass Recipe - Amber Tint", glass([0.84, 0.38, 0.08], 1.51, 3.0, GlassBoundary::Closed)),
VisualPreset::glass("starter.glass.cobalt-blue", "Glass Recipe - Cobalt Blue", glass([0.06, 0.14, 0.76], 1.53, 3.0, GlassBoundary::Closed)),
VisualPreset::glass(
"starter.glass.thin-clear",
"Glass Recipe - Thin Clear Sheet",
glass([0.95, 0.98, 0.99], 1.50, 2.0, GlassBoundary::Thin { thickness_mm: 2.0 }),
),
VisualPreset::opaque(
"starter.solar.mono-blue",
"Solar Panel - Mono Blue Uniform Preview",
Category::Solar,
look([20, 41, 85], 0.0, 0.20, 1.5),
SOLAR,
maps("starter.solar.mono-blue.maps-v1", [160.0, 160.0], false),
),
VisualPreset::opaque(
"starter.solar.thin-film-dark",
"Solar Panel - Dark Thin Film Uniform Preview",
Category::Solar,
look([28, 33, 42], 0.0, 0.28, 1.5),
SOLAR,
maps("starter.solar.thin-film-dark.maps-v1", [100.0, 200.0], false),
),
VisualPreset::opaque(
"starter.regolith.mare-charcoal",
"Regolith - Mare Charcoal Uniform Preview",
Category::Regolith,
look([56, 56, 55], 0.0, 0.97, 1.5),
REGOLITH,
maps("starter.regolith.mare-charcoal.maps-v1", [200.0, 200.0], false),
),
VisualPreset::opaque(
"starter.regolith.highland-pale",
"Regolith - Highland Pale Uniform Preview",
Category::Regolith,
look([116, 113, 106], 0.0, 0.93, 1.5),
REGOLITH,
maps("starter.regolith.highland-pale.maps-v1", [200.0, 200.0], false),
),
VisualPreset::opaque(
"starter.regolith.dust-neutral",
"Regolith - Neutral Dust Uniform Preview",
Category::Regolith,
look([86, 85, 81], 0.0, 1.0, 1.5),
REGOLITH,
maps("starter.regolith.dust-neutral.maps-v1", [150.0, 150.0], false),
),
VisualPreset::opaque(
"starter.regolith.agglutinate-dark",
"Regolith - Dark Agglutinate Uniform Preview",
Category::Regolith,
look([66, 63, 60], 0.0, 0.89, 1.5),
REGOLITH,
maps("starter.regolith.agglutinate-dark.maps-v1", [200.0, 200.0], false),
),
];
+3
View File
@@ -1012,9 +1012,11 @@
"render.image_help": "Náhledový obrázek z CPU · PNG · přibližná průhlednost",
"render.image_overwrite": "Nahradit existující soubor",
"render.image_preparing": "Příprava obrázku",
"render.image_progressive": "Progresivní render",
"render.image_queued": "Čeká na render",
"render.image_reduce": "Zkuste menší obrázek nebo jednodušší scénu.",
"render.image_running": "Renderování obrázku",
"render.image_samples": "Vzorků na pixel",
"render.image_stale": "Tento obrázek používá scénu zachycenou před pozdějšími změnami.",
"render.image_start": "Spustit render",
"render.image_supported": "Požadavek na render je podporován; fronta a paměť se znovu ověří při spuštění.",
@@ -1030,6 +1032,7 @@
"render.perspective": "Perspektivní kamera",
"render.preview": "Integrovaný náhled",
"render.preview_help": "Rastrové stínování a smíšená průhlednost. Lom světla, HDRI a finální render zatím nejsou k dispozici.",
"render.progressive_image_summary": "Náhledové PNG · neprůhledné PBR · postupné vzorkování",
"render.reload_settings": "Znovu načíst uložené nastavení",
"render.restore_camera": "Obnovit uloženou kameru",
"render.return_design": "Zpět k návrhu",
+6
View File
@@ -1341,6 +1341,9 @@
"recovery.never_saved": "never saved",
"render.background": "Scene background",
"render.fit": "Fit Product",
"render.gpu_path_failed": "GPU path tracer unavailable",
"render.gpu_path_first_setup": "The first setup on this GPU can take several minutes; SolidCraft stays usable meanwhile. Later starts reuse the compiled shaders where the driver keeps them.",
"render.gpu_path_preparing": "GPU path tracer: compiling shaders…",
"render.image_cancelled": "Render cancelled",
"render.image_cancelling": "Cancelling",
"render.image_completed": "Image ready to export",
@@ -1351,9 +1354,11 @@
"render.image_help": "CPU preview image · PNG · approximate transparency",
"render.image_overwrite": "Replace existing file",
"render.image_preparing": "Preparing image",
"render.image_progressive": "Progressive rendering",
"render.image_queued": "Waiting to render",
"render.image_reduce": "Try a smaller image or simpler scene.",
"render.image_running": "Rendering image",
"render.image_samples": "Samples per pixel",
"render.image_stale": "This image uses the scene captured before subsequent changes.",
"render.image_start": "Start Render",
"render.image_supported": "Render request is supported; the queue and memory pool are checked again at start.",
@@ -1369,6 +1374,7 @@
"render.perspective": "Perspective camera",
"render.preview": "Integrated preview",
"render.preview_help": "Raster shading and blended transparency. Refraction, HDRI and final rendering are not available yet.",
"render.progressive_image_summary": "Preview PNG · PBR and glass · progressive samples",
"render.reload_settings": "Reload Saved Settings",
"render.restore_camera": "Restore Saved Camera",
"render.return_design": "Return to Design",
+6
View File
@@ -1341,6 +1341,9 @@
"recovery.never_saved": "未保存",
"render.background": "シーンの背景",
"render.fit": "製品を画面に合わせる",
"render.gpu_path_failed": "GPUパストレーサーを利用できません",
"render.gpu_path_first_setup": "このGPUでの初回セットアップには数分かかることがあります。その間もSolidCraftは使用できます。以降の起動では、ドライバーが保持していればコンパイル済みのシェーダーを再利用します。",
"render.gpu_path_preparing": "GPUパストレーサー:シェーダーをコンパイル中…",
"render.image_cancelled": "レンダリングをキャンセルしました",
"render.image_cancelling": "キャンセル中",
"render.image_completed": "画像をエクスポートできます",
@@ -1351,9 +1354,11 @@
"render.image_help": "CPUプレビュー画像 · PNG · 近似透過",
"render.image_overwrite": "既存のファイルを置換",
"render.image_preparing": "画像を準備中",
"render.image_progressive": "プログレッシブレンダリング",
"render.image_queued": "レンダリング待機中",
"render.image_reduce": "画像を小さくするかシーンを簡略化してください。",
"render.image_running": "画像をレンダリング中",
"render.image_samples": "ピクセルあたりのサンプル数",
"render.image_stale": "この画像は後続の変更前に取得したシーンを使用しています。",
"render.image_start": "レンダリングを開始",
"render.image_supported": "レンダリング要求に対応しています。開始時にキューとメモリを再確認します。",
@@ -1369,6 +1374,7 @@
"render.perspective": "透視投影カメラ",
"render.preview": "統合プレビュー",
"render.preview_help": "ラスタシェーディングとブレンド透明度を使用します。屈折、HDRI、最終レンダリングはまだ利用できません。",
"render.progressive_image_summary": "プレビューPNG · 不透明PBR · プログレッシブサンプリング",
"render.reload_settings": "保存した設定を再読み込み",
"render.restore_camera": "保存したカメラを復元",
"render.return_design": "設計に戻る",
+6
View File
@@ -1341,6 +1341,9 @@
"recovery.never_saved": "从未保存",
"render.background": "场景背景",
"render.fit": "适配产品视图",
"render.gpu_path_failed": "GPU 路径追踪器不可用",
"render.gpu_path_first_setup": "首次在此 GPU 上设置可能需要几分钟,期间 SolidCraft 仍可正常使用。之后启动时,如果驱动程序保留了已编译的着色器,将直接复用。",
"render.gpu_path_preparing": "GPU 路径追踪器:正在编译着色器…",
"render.image_cancelled": "渲染已取消",
"render.image_cancelling": "正在取消",
"render.image_completed": "图像可导出",
@@ -1351,9 +1354,11 @@
"render.image_help": "CPU 预览图像 · PNG · 近似透明度",
"render.image_overwrite": "替换现有文件",
"render.image_preparing": "正在准备图像",
"render.image_progressive": "渐进式渲染",
"render.image_queued": "等待渲染",
"render.image_reduce": "请尝试更小的图像或更简单的场景。",
"render.image_running": "正在渲染图像",
"render.image_samples": "每像素采样数",
"render.image_stale": "此图像使用后续更改之前捕获的场景。",
"render.image_start": "开始渲染",
"render.image_supported": "支持此渲染请求;开始时将再次检查队列和内存池。",
@@ -1369,6 +1374,7 @@
"render.perspective": "透视相机",
"render.preview": "集成预览",
"render.preview_help": "光栅着色和混合透明度。暂不支持折射、HDRI 和最终渲染。",
"render.progressive_image_summary": "预览 PNG · 不透明 PBR · 渐进采样",
"render.reload_settings": "重新加载已保存设置",
"render.restore_camera": "恢复已保存相机",
"render.return_design": "返回设计",
+123 -6
View File
@@ -138,12 +138,11 @@ pub use defaults::LIBRARY;
/// A library appearance by name (case-insensitive).
pub fn library(name: &str) -> Option<Look> {
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look {
name: n.to_string(),
color: *c,
opacity: *o,
pbr: None,
})
LIBRARY
.iter()
.find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim()))
.map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o, pbr: None })
.or_else(|| crate::appearance_pack::find(name)?.opaque_look().ok())
}
/// The look a physical material gives a body that has no appearance of its own.
@@ -327,4 +326,122 @@ mod tests {
assert_eq!(Look::custom([1, 2, 3], 7.0).opacity, 1.0);
assert_eq!(Look::custom([255, 0, 16], 1.0).hex(), "#ff0010");
}
mod starter_pack {
use crate::appearance::Look;
use crate::appearance_pack::*;
use std::collections::BTreeSet;
#[test]
fn stable_ids_categories_and_original_licences_cover_the_requested_pack() {
let mut ids = BTreeSet::new();
let mut names = BTreeSet::new();
for preset in &STARTER_PACK {
assert!(preset.id.starts_with("starter.") && preset.id.len() <= 128);
assert!(preset.id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-'));
assert!(ids.insert(preset.id));
assert!(names.insert(preset.name.to_ascii_lowercase()));
assert_eq!(preset.revision, 1);
assert_eq!(preset.provenance.license, "MIT OR Apache-2.0");
assert!(!preset.provenance.author.is_empty());
assert_eq!(preset.provenance.source, "assets/appearances/starter_pack.rs");
assert_eq!(find(preset.id), Some(preset));
assert_eq!(find(preset.name), Some(preset));
}
for (category, count) in [
(Category::Wood, 8),
(Category::Metal, 10),
(Category::Plastic, 10),
(Category::Glass, 6),
(Category::Solar, 2),
(Category::Regolith, 4),
] {
assert_eq!(STARTER_PACK.iter().filter(|p| p.category == category).count(), count);
}
}
#[test]
fn opaque_construction_and_owned_look_roundtrip_preserve_current_response() {
let mut admitted = 0;
for preset in &STARTER_PACK {
if let Recipe::Opaque(recipe) = preset.recipe {
let look = preset.opaque_look().unwrap();
assert_eq!(look.opacity, 1.0);
assert_eq!(look.color, recipe.color);
let surface = look.opaque_surface().unwrap().unwrap();
assert_eq!(surface.metallic(), recipe.metallic);
assert_eq!(surface.roughness(), recipe.roughness);
let json = serde_json::to_value(&look).unwrap();
assert_eq!(serde_json::from_value::<Look>(json.clone()).unwrap(), look);
assert!(json.get("provenance").is_none() && json.get("requirements").is_none() && json.get("id").is_none());
assert_eq!(preset.requirements, &[Requirement::OpaquePbr]);
admitted += 1;
}
}
assert_eq!(admitted, 34);
}
#[test]
fn glass_transmission_recipes_are_valid_but_never_scalar_or_alpha_looks() {
for preset in STARTER_PACK.iter().filter(|p| p.category == Category::Glass) {
let Recipe::Glass(recipe) = preset.recipe else { panic!("glass category must carry a glass recipe") };
recipe.validate().unwrap();
assert!(!preset.is_opaque());
assert!(preset.opaque_look().is_err());
assert_eq!(preset.requirements, &[Requirement::Refraction, Requirement::CertifiedThinOrClosed]);
// Compatibility of illustrative values only: the real optics adapter must
// derive and admit these absorption values after geometry certification.
assert!(recipe.transmission_linear_rgb.iter().all(|t| (-t.ln() / recipe.reference_path_mm) <= 100.0));
for channel in [0.0, -1.0, 1.01, f64::NAN, f64::INFINITY] {
let mut bad = recipe;
bad.transmission_linear_rgb[0] = channel;
assert!(bad.validate().is_err());
}
let mut bad = recipe;
bad.reference_path_mm = 0.0;
assert!(bad.validate().is_err());
bad = recipe;
bad.boundary = GlassBoundary::Thin { thickness_mm: 10001.0 };
assert!(bad.validate().is_err());
}
}
#[test]
fn legacy_library_and_six_existing_finish_names_are_not_overridden_or_duplicated() {
assert_eq!(crate::appearance::LIBRARY.len(), 20);
for (name, color, opacity) in crate::appearance::LIBRARY {
let look = crate::appearance::library(name).unwrap();
assert_eq!(look.color, color);
assert_eq!(look.opacity, opacity);
assert!(look.pbr.is_none());
assert!(find(name).is_none());
}
for name in [
"Steel - Satin PBR",
"Aluminum - Satin PBR",
"Copper - Clean PBR",
"ABS - Molded PBR",
"PLA - Smooth PBR",
"Wood - Oak Uniform PBR Preview",
] {
assert!(STARTER_PACK.iter().all(|p| !p.name.eq_ignore_ascii_case(name)), "existing #1366 finish must remain independent");
}
}
#[test]
fn uniform_previews_and_planned_resource_scale_are_explicit_and_not_bindings() {
let mut recipes = 0;
for preset in &STARTER_PACK {
if let Some(recipe) = preset.future_texture_recipe {
assert!(preset.name.contains("Uniform Preview"));
assert!(preset.preview_note.contains("Uniform opaque preview only"));
assert_eq!(recipe.id, format!("{}.maps-v1", preset.id));
assert!(recipe.repeat_mm.iter().all(|v| v.is_finite() && *v > 0.0 && *v <= 10000.0));
assert_eq!(recipe.grain_axis, if preset.category == Category::Wood { Some(TextureAxis::V) } else { None });
assert_eq!(preset.requirements, &[Requirement::OpaquePbr]);
recipes += 1;
}
}
assert_eq!(recipes, 14);
}
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Original visual authoring choices; never engineering material grades or a catalog codec.
use serde::Serialize;
use crate::{DocError, Result, appearance::Look, pbr_appearance::PbrParameters};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Category {
Wood,
Metal,
Plastic,
Glass,
Solar,
Regolith,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Requirement {
OpaquePbr,
Refraction,
CertifiedThinOrClosed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TextureAxis {
V,
}
/// Planned original procedural resources, not a loaded texture or a saved binding.
/// Albedo is sRGB; roughness and positive-Y tangent normals are linear data.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct TextureRecipe {
pub id: &'static str,
pub repeat_mm: [f64; 2],
pub grain_axis: Option<TextureAxis>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct Provenance {
pub author: &'static str,
pub license: &'static str,
pub source: &'static str,
pub kind: &'static str,
}
pub const ORIGINAL: Provenance = Provenance {
author: "SolidCraft contributors",
license: "MIT OR Apache-2.0",
source: "assets/appearances/starter_pack.rs",
kind: "original illustrative optical authoring; not measured material data",
};
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct OpaqueRecipe {
pub color: [u8; 3],
pub metallic: f64,
pub roughness: f64,
pub dielectric_ior: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum GlassBoundary {
Thin { thickness_mm: f64 },
Closed,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct GlassRecipe {
pub dielectric_ior: f64,
/// Linear transmittance over reference_path_mm, not sRGB tint or alpha.
pub transmission_linear_rgb: [f64; 3],
pub reference_path_mm: f64,
pub boundary: GlassBoundary,
}
impl GlassRecipe {
pub fn validate(&self) -> Result<()> {
if !self.dielectric_ior.is_finite()
|| !(1.0..=3.0).contains(&self.dielectric_ior)
|| self.transmission_linear_rgb.iter().any(|v| !v.is_finite() || *v <= 0.0 || *v > 1.0)
|| !self.reference_path_mm.is_finite()
|| self.reference_path_mm <= 0.0
|| self.reference_path_mm > 1e6
|| matches!(self.boundary,GlassBoundary::Thin {thickness_mm} if !thickness_mm.is_finite() || thickness_mm<=0.0 || thickness_mm>10000.0)
{
return Err(DocError::Invalid(
"invalid illustrative glass recipe: finite IOR1..3, linear transmission0<T<=1, bounded positive reference path/thickness required"
.into(),
));
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Recipe {
Opaque(OpaqueRecipe),
Glass(GlassRecipe),
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct VisualPreset {
pub id: &'static str,
pub name: &'static str,
pub category: Category,
pub revision: u32,
pub provenance: Provenance,
pub requirements: &'static [Requirement],
pub recipe: Recipe,
pub preview_note: &'static str,
pub future_texture_recipe: Option<TextureRecipe>,
}
impl VisualPreset {
pub const fn opaque(
id: &'static str,
name: &'static str,
category: Category,
recipe: OpaqueRecipe,
preview_note: &'static str,
future_texture_recipe: Option<TextureRecipe>,
) -> Self {
Self {
id,
name,
category,
revision: 1,
provenance: ORIGINAL,
requirements: &[Requirement::OpaquePbr],
recipe: Recipe::Opaque(recipe),
preview_note,
future_texture_recipe,
}
}
pub const fn glass(id: &'static str, name: &'static str, recipe: GlassRecipe) -> Self {
Self {
id,
name,
category: Category::Glass,
revision: 1,
provenance: ORIGINAL,
requirements: &[Requirement::Refraction, Requirement::CertifiedThinOrClosed],
recipe: Recipe::Glass(recipe),
preview_note: "Recipe only: requires actual refraction and a certified thin/closed geometry path; no alpha substitute",
future_texture_recipe: None,
}
}
pub fn is_opaque(&self) -> bool {
matches!(self.recipe, Recipe::Opaque(_))
}
pub fn preview_color(&self) -> [u8; 3] {
match self.recipe {
Recipe::Opaque(r) => r.color,
// A UI swatch of the transmission values, not rendered glass or absorption.
Recipe::Glass(r) => r.transmission_linear_rgb.map(|v| {
let encoded = if v <= 0.0031308 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
(encoded.clamp(0.0, 1.0) * 255.0).round() as u8
}),
}
}
/// All opaque presets share the existing finite PBR constructor and owned Look.
/// Refraction recipes cannot be smuggled into a scalar/alpha appearance.
pub fn opaque_look(&self) -> Result<Look> {
let Recipe::Opaque(recipe) = self.recipe else {
return Err(DocError::Invalid(
"glass recipe unavailable: actual refraction and certified thin/closed geometry are required; alpha transparency is not a substitute"
.into(),
));
};
let pbr = PbrParameters::new(recipe.metallic, recipe.roughness, recipe.dielectric_ior).map_err(|e| DocError::Invalid(e.to_string()))?;
Ok(Look { name: self.name.into(), color: recipe.color, opacity: 1.0, pbr: Some(pbr) })
}
}
#[path = "../../../assets/appearances/starter_pack.rs"]
mod data;
pub use data::STARTER_PACK;
/// Stable IDs are preferred for commands; display-name lookup is unambiguous.
pub fn find(id_or_name: &str) -> Option<&'static VisualPreset> {
let key = id_or_name.trim();
STARTER_PACK.iter().find(|p| p.id.eq_ignore_ascii_case(key) || p.name.eq_ignore_ascii_case(key))
}
+6
View File
@@ -3709,8 +3709,14 @@ pub struct UserMaterial {
pub density: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub appearance: Option<crate::appearance::Look>,
/// Candidate engineering data; independent of assigned mass density and optics.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub physical_card: Option<crate::physical_material::ValidatedCard>,
}
/// Bound design-owned definitions consistently in commands and normal file loading.
pub const MAX_USER_MATERIALS: usize = 1000;
/// Largest density a material may have (g/cm³); the densest elements are about 22.6.
pub const MAX_DENSITY: f64 = 1000.0;
+29 -3
View File
@@ -1041,10 +1041,36 @@ const MAX_REAL: f64 = 1e9;
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
/// already bounds its nesting.
fn check_design_numbers(v: &Value) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != "metadata").try_for_each(|(_, x)| check_numbers(x, 1)),
v => check_numbers(v, 0),
let Some(root) = v.as_object() else { return check_numbers(v, 0) };
for (key, value) in root {
if key == "metadata" {
continue;
}
if key != "user_materials" {
check_numbers(value, 1)?;
continue;
}
let Some(materials) = value.as_array() else { return check_numbers(value, 1) };
if materials.len() > crate::MAX_USER_MATERIALS {
return Err(DocError::Invalid("document exceeds 1000 user materials".into()));
}
for material in materials {
let Some(fields) = material.as_object() else {
check_numbers(material, 2)?;
continue;
};
for (field, data) in fields {
// SI engineering constants have separate typed bounds. No other field,
// including nested fields with this name, bypasses coordinate limits.
if field == "physical_card" && !data.is_null() {
crate::physical_material::validate_card(data)?;
} else {
check_numbers(data, 3)?;
}
}
}
}
Ok(())
}
/// The range check again, on the typed design: an integer such as `9223372036854775808` passes
@@ -0,0 +1,5 @@
//! User materials can save a validated SI engineering card (`physical_card`) with property
//! conditions and source claims, independently of assigned mass density and visual appearance.
//! Older designs need no transformation: an absent card means unspecified engineering data.
//! The last release refuses this newer format; pending builds without this change refuse its
//! `material-engineering-cards` descriptor instead of silently dropping the saved card.
+1
View File
@@ -6,6 +6,7 @@
#![forbid(unsafe_code)]
pub mod appearance;
pub mod appearance_pack;
mod assembly;
mod body_transfer;
mod body_transfer_sheet;
+25
View File
@@ -17,6 +17,31 @@ pub struct MaterialCard {
pub properties: Vec<Property>,
}
/// Design-owned candidate data. Private storage prevents bypassing admission;
/// validation does not certify sources, applicability or analysis capability.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(transparent)]
pub struct ValidatedCard(MaterialCard);
impl ValidatedCard {
pub fn from_value(value: &Value) -> Result<Self> {
let card = admit_card(value)?;
card.validate()?;
Ok(Self(card))
}
pub fn card(&self) -> &MaterialCard {
&self.0
}
}
impl<'de> Deserialize<'de> for ValidatedCard {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
Self::from_value(&value).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Family {
+1 -1
View File
@@ -261,7 +261,7 @@ fn a_body_reads_the_material_its_mass_is_computed_with() {
let usable = [("Walnut", 0.65), ("Mote", f64::MIN_POSITIVE), ("Limit", MAX_DENSITY)];
let unusable = [("Broken", -3.0), ("Weightless", 0.0), ("Beyond", MAX_DENSITY.next_up())];
for (name, density) in usable.into_iter().chain(unusable) {
doc.user_materials.push(UserMaterial { name: name.into(), density, appearance: None });
doc.user_materials.push(UserMaterial { name: name.into(), density, appearance: None, physical_card: None });
}
// (body, its assignment as stored, the material it reads and weighs, that material's density)
let cases = [
+103 -50
View File
@@ -30,19 +30,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); omitted/null uses legacy scalar shading. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
appearance?: library name or stable starter.* ID (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
name?: inert copied look label (requires color, mutually exclusive with appearance); pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); a starter ID or registered starter name resolves its recipe before color overrides; omitted pbr retains that response and pbr: null explicitly clears it. Unavailable glass recipes refuse through either selector. Other custom labels with an explicit color retain legacy scalar behavior. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
),
CommandSpec::new("appearance.library", "Appearance Library", appearance_library).icon("library")
.noundo()
.params("→ the built-in appearances (name, colour, opacity)"),
.params("→ 20 legacy scalar appearances and 40 starter recipes with IDs, categories, provenance, requirements and availability; six starter glass recipes are unavailable"),
CommandSpec::new("appearance.list", "List Appearances", appearance_list).icon("list")
.noundo()
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).icon("list")
.noundo()
.doc_only()
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", DESCRIBE_CONTRACT, describe))
.icon("list")
@@ -387,36 +387,68 @@ pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
Some([c(0)?, c(1)?, c(2)?])
}
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
use solidcraft_doc::appearance::{FaceLook, Look, library};
let cmd = "appearance.assign";
let mut look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
// A colour with a name: that colour, called that (a name not in the library is fine).
(Some(n), Some(v)) if !v.is_null() => {
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
let o = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(1.0);
let mut l = Look::custom(c, o);
l.name = n.chars().take(128).collect();
Some(l)
// A saved label is data, not a request to resolve a recipe. Keep the explicit
// appearance selector separate so an owned look may have any recipe's ID as its name.
let copied_name = match p.get("name") {
None | Some(Value::Null) => None,
Some(Value::String(name)) if !name.is_empty() && name.chars().count() <= 128 && !name.chars().any(char::is_control) => Some(name),
_ => return Err(bad(cmd, "`name` must be a nonempty label of at most 128 characters without controls")),
};
if copied_name.is_some() && (p.get("color").is_none_or(Value::is_null) || p.get("appearance").is_some_and(|value| !value.is_null())) {
return Err(bad(cmd, "`name` requires an explicit color and cannot be combined with `appearance`"));
}
// Both the stable ID and a registered display name explicitly select a recipe.
// Copied labels use `name`, so color overrides cannot bypass unsupported optics.
let preset = str_(p, "appearance").and_then(solidcraft_doc::appearance_pack::find);
let mut look: Option<Look> = if let Some(preset) = preset {
// Refuse unsupported optics before any color/PBR/opacity override.
let mut look = preset.opaque_look().map_err(|e| bad(cmd, e.to_string()))?;
if let Some(color) = p.get("color").filter(|v| !v.is_null()) {
look.color = color_param(color).ok_or_else(|| bad(cmd, "invalid preset color override"))?;
}
(Some(n), _) => {
let mut l = library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?;
if let Some(o) = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()) {
l.opacity = o.clamp(0.0, 1.0);
if let Some(opacity) = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()) {
look.opacity = opacity.clamp(0.0, 1.0);
}
Some(look)
} else {
match (str_(p, "appearance"), p.get("color")) {
// A colour with a name: that colour, called that (a name not in the library is fine).
(Some(n), Some(v)) if !v.is_null() => {
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
let o = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(1.0);
let mut l = Look::custom(c, o);
l.name = n.chars().take(128).collect();
Some(l)
}
(Some(n), _) => {
let mut l = if let Some(preset) = solidcraft_doc::appearance_pack::find(n) {
preset.opaque_look().map_err(|e| bad(cmd, e.to_string()))?
} else {
library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?
};
if let Some(o) = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()) {
l.opacity = o.clamp(0.0, 1.0);
}
Some(l)
}
(None, None | Some(Value::Null)) => None,
(None, Some(v)) => {
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
let o = match p.get("opacity") {
Some(o) => o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?,
None => 1.0,
};
Some(Look::custom(c, o))
}
Some(l)
}
(None, None | Some(Value::Null)) => None,
(None, Some(v)) => {
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
let o = match p.get("opacity") {
Some(o) => o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?,
None => 1.0,
};
Some(Look::custom(c, o))
}
};
if let Some(l) = &mut look {
if let Some(name) = copied_name {
l.name = name.clone();
}
attach_pbr(cmd, p, l)?;
} else if p.get("pbr").is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, "`pbr` requires a color or appearance"));
@@ -543,38 +575,52 @@ pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
mod pbr_tests;
pub(super) fn attach_pbr(cmd: &str, params: &Value, look: &mut solidcraft_doc::appearance::Look) -> Result<()> {
let Some(value) = params.get("pbr") else { return Ok(()) };
if value.is_null() {
look.pbr = None;
return Ok(());
if let Some(value) = params.get("pbr") {
look.pbr = if value.is_null() {
None
} else {
let object = value
.as_object()
.filter(|o| o.len() == 3 && o.keys().all(|k| matches!(k.as_str(), "metallic" | "roughness" | "dielectric_ior")))
.ok_or_else(|| bad(cmd, "`pbr` requires exactly metallic, roughness and dielectric_ior"))?;
let number = |key: &str| object.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("`pbr.{key}` must be a finite number")));
Some(
solidcraft_doc::pbr_appearance::PbrParameters::new(number("metallic")?, number("roughness")?, number("dielectric_ior")?)
.map_err(|e| bad(cmd, e.to_string()))?,
)
};
}
if let Some(opacity) = params.get("opacity")
&& opacity.as_f64().filter(|v| v.is_finite() && *v == 1.0).is_none()
{
return Err(bad(cmd, "opaque PBR requires numeric opacity 1"));
// Validate the final look, including a named preset's inherited response.
// Both appearance assignments and physical-material defaults use this path.
if look.pbr.is_some() {
if let Some(opacity) = params.get("opacity")
&& opacity.as_f64().filter(|v| v.is_finite() && *v == 1.0).is_none()
{
return Err(bad(cmd, "opaque PBR requires numeric opacity 1"));
}
if look.opacity != 1.0 {
return Err(bad(cmd, "opaque PBR requires opacity 1; refraction/transmission is not supported"));
}
}
let object = value
.as_object()
.filter(|o| o.len() == 3 && o.keys().all(|k| matches!(k.as_str(), "metallic" | "roughness" | "dielectric_ior")))
.ok_or_else(|| bad(cmd, "`pbr` requires exactly metallic, roughness and dielectric_ior"))?;
let number = |key: &str| object.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("`pbr.{key}` must be a finite number")));
let response = solidcraft_doc::pbr_appearance::PbrParameters::new(number("metallic")?, number("roughness")?, number("dielectric_ior")?)
.map_err(|e| bad(cmd, e.to_string()))?;
if look.opacity != 1.0 {
return Err(bad(cmd, "opaque PBR requires opacity 1; refraction/transmission is not supported"));
}
look.pbr = Some(response);
Ok(())
}
fn appearance_library(_s: &mut Session, _p: &Value) -> Result<Value> {
let list: Vec<Value> = solidcraft_doc::appearance::LIBRARY
let mut list: Vec<Value> = solidcraft_doc::appearance::LIBRARY
.iter()
.map(|(n, c, o)| {
let category = n.split(" - ").next().unwrap_or(n);
json!({"name": n, "category": category, "color": format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]), "opacity": o})
})
.collect();
for preset in &solidcraft_doc::appearance_pack::STARTER_PACK {
let c = preset.preview_color();
let pbr = preset.opaque_look().ok().and_then(|look| look.pbr);
list.push(json!({"id":preset.id,"name":preset.name,"category":preset.category,"revision":preset.revision,
"color":format!("#{:02x}{:02x}{:02x}",c[0],c[1],c[2]),"opacity":1.0,"pbr":pbr,
"available":preset.is_opaque(),"requirements":preset.requirements,"provenance":preset.provenance,
"recipe":preset.recipe,"preview_note":preset.preview_note,"future_texture_recipe":preset.future_texture_recipe}));
}
Ok(json!({"appearances": list}))
}
@@ -640,8 +686,15 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
let user = s.doc.user_materials.iter().map(
|m| json!({"name": m.name, "density_g_cm3": m.density, "appearance": s.doc.material_look(&m.name).as_ref().map(look_json), "user": true}),
);
let user = s
.doc
.user_materials
.iter()
.map(|m| {
let mut out = super::materials::material_json(m)?;
out["appearance"] = json!(s.doc.material_look(&m.name).as_ref().map(look_json));
Ok(out)
})
.collect::<Result<Vec<_>>>()?;
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
}
+20 -5
View File
@@ -1,4 +1,4 @@
//! Scalar local libraries: inspect/export files and copy exact revisions into a design.
//! Local material libraries: inspect/export files and copy exact revisions into a design.
use super::CommandSpec;
use crate::{Result, Session, params::bad};
use serde::Deserialize;
@@ -7,9 +7,9 @@ use solidcraft_io::catalog::{Catalog, Definition, Provenance};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("library.inspect", "Inspect Local Library", inspect).icon("inspect").noundo().doc_only()
.params("path: scalar library JSON; returns validated catalog and exact entry revisions, without changing the design"),
.params("path: v1/v2 library JSON; returns validated catalog and exact entry revisions, without changing the design"),
CommandSpec::new("library.export", "Export Design Library", export).icon("export").noundo().doc_only()
.params("path: .json; id? (default user.local), name?, provenance?, overwrite? false. Copies assigned looks and design-local physical definitions; never changes the design"),
.params("path: .json; id? (default user.local), name?, provenance?, overwrite? false, format? solidcraft-library/2 (default) | solidcraft-library/1 (refuses PBR/cards). Copies assigned looks and design-local physical definitions; never changes the design"),
CommandSpec::new("library.apply", "Apply Local Library Entry", apply).icon("import").enabled(super::not_in_sketch)
.params("path, entry: qualified ID, revision: exact content SHA-256; targets?: {bodies?, faces?: [{body,point}], components?}; name?: physical material name on import. Copies values in one undo step; material conflicts are explicit"),
];
@@ -31,6 +31,11 @@ struct Export {
provenance: Provenance,
#[serde(default)]
overwrite: bool,
#[serde(default = "default_format")]
format: String,
}
fn default_format() -> String {
solidcraft_io::catalog::FORMAT.into()
}
fn default_id() -> String {
"user.local".into()
@@ -107,7 +112,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if !request.path.to_ascii_lowercase().ends_with(".json") {
return Err(bad(cmd, "library export path must end in .json"));
}
let catalog = Catalog::from_document(&s.doc, &request.id, &request.name, request.provenance).map_err(|e| bad(cmd, e.to_string()))?;
let catalog = Catalog::from_document(&s.doc, &request.id, &request.name, request.provenance)
.and_then(|catalog| catalog.in_format(&request.format))
.map_err(|e| bad(cmd, e.to_string()))?;
let bytes = catalog.encode().map_err(|e| bad(cmd, e.to_string()))?;
#[cfg(not(target_arch = "wasm32"))]
let result = if request.overwrite {
@@ -143,9 +150,11 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "appearance entries need targets and do not take a material name"));
}
let mut target = serde_json::to_value(&request.targets).map_err(|e| bad(cmd, e.to_string()))?;
target["appearance"] = json!(look.name);
target["name"] = json!(look.name);
target["color"] = json!(look.color);
target["opacity"] = json!(look.opacity);
// Catalog labels are inert; a full replacement explicitly clears absent PBR.
target["pbr"] = json!(look.pbr);
nested(s, "appearance.assign", &target)?
}
Definition::Material(material) => {
@@ -172,6 +181,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(look) = &material.appearance {
params["color"] = json!(look.color);
params["opacity"] = json!(look.opacity);
if let Some(pbr) = look.pbr {
params["pbr"] = json!(pbr);
}
}
if let Some(card) = &material.physical_card {
params["physical_card"] = json!(card);
}
nested(s, "material.create", &params)?;
// The existing colour API labels a custom look after its material.
+114 -5
View File
@@ -1,5 +1,5 @@
use super::*;
use solidcraft_io::catalog::{Definition, Entry, FORMAT, ScalarLook, ScalarMaterial};
use solidcraft_io::catalog::{Definition, Entry, FORMAT, LibraryLook, LibraryMaterial};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
@@ -33,16 +33,17 @@ fn catalog() -> Catalog {
entries: vec![
Entry::new(
"user.test",
Definition::Appearance(ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }),
Definition::Appearance(LibraryLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8, pbr: None }),
Provenance::default(),
)
.unwrap(),
Entry::new(
"user.test",
Definition::Material(ScalarMaterial {
Definition::Material(LibraryMaterial {
name: "Timber density".into(),
density_g_cm3: 0.7,
appearance: Some(ScalarLook { name: "Timber tint".into(), color: [180, 120, 70], opacity: 1.0 }),
physical_card: None,
appearance: Some(LibraryLook { name: "Timber tint".into(), color: [180, 120, 70], opacity: 1.0, pbr: None }),
}),
Provenance::default(),
)
@@ -60,6 +61,113 @@ fn apply_entry(s: &mut Session, file: &File, entry: &Entry, targets: Value) -> R
s.execute("library.apply", &json!({"path":file.0,"entry":entry.id,"revision":entry.revision,"targets":targets}))
}
fn rich_catalog() -> Catalog {
use solidcraft_doc::{pbr_appearance::PbrParameters, physical_material::ValidatedCard};
let mut c = catalog();
if let Definition::Appearance(l) = &mut c.entries[0].definition {
l.opacity = 1.0;
l.pbr = Some(PbrParameters::new(1., 0.25, 1.5).unwrap());
}
if let Definition::Material(m) = &mut c.entries[1].definition {
m.density_g_cm3 = 0.8;
m.appearance.as_mut().unwrap().pbr = Some(PbrParameters::new(0., 0.65, 1.4).unwrap());
m.physical_card = Some(
ValidatedCard::from_value(&json!({"schema_version":1,"designation":"Original synthetic candidate","family":"other",
"sources":[{"id":"fixture","title":"Original invented density","kind":"illustrative"}],
"properties":[{"source_id":"fixture","data":{"kind":"density","kg_m3":700.0}}]}))
.unwrap(),
);
}
for e in &mut c.entries {
*e = Entry::new("user.test", e.definition.clone(), e.provenance.clone()).unwrap();
}
c
}
#[test]
fn rich_appearance_copy_preserves_product_response_and_mass_without_source_file() {
let file = File::new();
let c = rich_catalog();
file.write(&c);
let mut s = session();
let mass = s.execute("component.properties", &json!({})).unwrap();
let scene = crate::product_scene::extract(&s).unwrap();
let undo = s.undo.len();
apply_entry(&mut s, &file, &c.entries[0], json!({"bodies":["Body1"]})).unwrap();
assert_eq!(s.undo.len(), undo + 1);
let after = crate::product_scene::extract(&s).unwrap();
assert!(Arc::ptr_eq(&scene.meshes[0].mesh, &after.meshes[0].mesh));
assert_eq!(after.meshes[0].surface.unwrap().metallic(), 1.0);
assert_eq!(s.execute("component.properties", &json!({})).unwrap(), mass);
let saved = solidcraft_io::write_design(&s.doc);
std::fs::remove_file(&file.0).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc.appearances.bodies.is_empty());
s.execute("edit.redo", &json!({})).unwrap();
let mut reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
assert_eq!(reopened.doc.appearances, s.doc.appearances);
assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), mass);
}
#[test]
fn rich_material_copy_retains_engineering_card_label_optics_and_explicit_mass_density() {
let file = File::new();
let c = rich_catalog();
file.write(&c);
let mut s = session();
let undo = s.undo.len();
apply_entry(&mut s, &file, &c.entries[1], json!({"bodies":["Body1"]})).unwrap();
assert_eq!(s.undo.len(), undo + 1);
let material = &s.doc.user_materials[0];
assert_eq!(material.appearance.as_ref().unwrap().name, "Timber tint");
assert_eq!(material.appearance.as_ref().unwrap().pbr.unwrap().roughness(), 0.65);
assert!(material.physical_card.is_some());
assert_eq!(material.density, 0.8);
let mass = s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
assert!((mass - 0.8).abs() < 1e-8);
let saved = solidcraft_io::write_design(&s.doc);
std::fs::remove_file(&file.0).unwrap();
let reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
assert_eq!(reopened.doc.user_materials, s.doc.user_materials);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc.user_materials.is_empty());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc.user_materials, reopened.doc.user_materials);
}
#[test]
fn explicit_v1_export_refuses_richer_data_before_overwriting_and_scalar_v1_still_works() {
let file = File::new();
let sentinel = b"Existing catalog must survive";
std::fs::write(&file.0, sentinel).unwrap();
let mut s = session();
s.execute(
"appearance.assign",
&json!({"bodies":["Body1"],"color":"#b47846",
"pbr":{"metallic":1,"roughness":0.3,"dielectric_ior":1.5}}),
)
.unwrap();
let before = s.doc.clone();
let history = (s.undo.len(), s.redo.len(), s.revision);
assert!(s.execute("library.export", &json!({"path":file.0,"format":solidcraft_io::catalog::V1_FORMAT,"overwrite":true})).is_err());
assert_eq!(std::fs::read(&file.0).unwrap(), sentinel);
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!(history, (s.undo.len(), s.redo.len(), s.revision));
s.execute("library.export", &json!({"path":file.0,"overwrite":true})).unwrap();
assert_eq!(Catalog::read(&file.0).unwrap().format, FORMAT);
s.execute("appearance.assign", &json!({"bodies":["Body1"],"color":"#b47846"})).unwrap();
s.execute("library.export", &json!({"path":file.0,"format":solidcraft_io::catalog::V1_FORMAT,"overwrite":true})).unwrap();
assert_eq!(Catalog::read(&file.0).unwrap().format, solidcraft_io::catalog::V1_FORMAT);
let rich = rich_catalog();
file.write(&rich);
s.execute("material.create", &json!({"name":"Timber density","density_g_cm3":0.8,"color":"#b47846"})).unwrap();
let before = s.doc.clone();
let history = (s.undo.len(), s.redo.len(), s.revision);
assert!(apply_entry(&mut s, &file, &rich.entries[1], json!({"bodies":["Body1"]})).is_err());
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!(history, (s.undo.len(), s.redo.len(), s.revision));
}
#[test]
fn catalog_export_obeys_file_scope_in_new_file_and_overwrite_modes() {
let file = File::new();
@@ -237,7 +345,8 @@ fn lossy_scalar_export_never_creates_or_replaces_a_file_or_changes_history() {
std::fs::write(&file.0, b"preserve existing library").unwrap();
}
for overwrite in [false, true] {
let error = s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite})).unwrap_err();
let error =
s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite,"format":solidcraft_io::catalog::V1_FORMAT})).unwrap_err();
assert!(error.to_string().contains("cannot preserve PBR"));
if existing {
assert_eq!(std::fs::read(&file.0).unwrap(), b"preserve existing library");
@@ -0,0 +1,243 @@
use crate::{Session, doc::Document, product_scene};
use serde_json::{Value, json};
use std::sync::Arc;
fn card() -> Value {
json!({"schema_version":1,"designation":"Original synthetic card, not design data","family":"other",
"sources":[{"id":"fixture","title":"Original analytic constants","kind":"illustrative","license":"MIT OR Apache-2.0"}],
"properties":[{"source_id":"fixture","conditions":{"temperature_k":293.15,"state":"synthetic"},
"data":{"kind":"density","kg_m3":700.0}},
{"source_id":"fixture","data":{"kind":"elasticity","model":{
"symmetry":"isotropic","young_modulus_pa":12.25e9,"poisson_ratio":0.25}}}]})
}
fn fixture() -> Session {
let mut s = Session::default();
s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":20,"height":5})).unwrap();
s.execute(
"material.create",
&json!({"name":"Synthetic","density_g_cm3":0.8,"color":"#b47846",
"pbr":{"metallic":0,"roughness":0.4,"dielectric_ior":1.5}}),
)
.unwrap();
s.execute("material.assign", &json!({"bodies":["Specimen"],"material":"Synthetic"})).unwrap();
s
}
#[test]
fn card_edits_preserve_mass_optics_and_geometry_and_survive_undo_reopen() {
let mut s = fixture();
let scene = product_scene::extract(&s).unwrap();
let mass = s.execute("component.properties", &json!({})).unwrap();
let before_undo = s.undo.len();
let out = s.execute("material.edit", &json!({"material":"Synthetic","physical_card":card()})).unwrap();
assert_eq!(out["physical_validation"]["isotropic_moduli"]["shear_pa"], 4.9e9);
assert_eq!(out["density_comparison"]["assigned_density_kg_m3"], 800.0);
assert_eq!(out["density_comparison"]["matches_within_relative_tolerance"], false);
assert_eq!(out["physical_validation"]["source_review_required"], json!(["fixture"]));
assert_eq!(out["physical_validation"]["analysis_solver_available"], false);
assert_eq!(s.undo.len(), before_undo + 1);
assert_eq!(s.execute("component.properties", &json!({})).unwrap(), mass);
let after = product_scene::extract(&s).unwrap();
assert!(Arc::ptr_eq(&scene.meshes[0].mesh, &after.meshes[0].mesh));
assert_eq!(scene.meshes[0].surface, after.meshes[0].surface);
let saved = s.doc.to_json();
assert_eq!(serde_json::from_str::<Value>(&saved).unwrap()["format"], solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc.user_materials[0].physical_card.is_none());
s.execute("edit.redo", &json!({})).unwrap();
let mut reopened = Session::new(Document::from_json(&saved).unwrap());
assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), mass);
assert_eq!(reopened.doc.user_materials, s.doc.user_materials);
let modulus = reopened.doc.user_materials[0].physical_card.as_ref().unwrap().card().directional_modulus([1., 2., 3.]).unwrap();
assert_eq!(modulus.young_modulus_pa, 12.25e9);
let doc = reopened.doc.clone();
let before = (reopened.revision, reopened.undo.len(), reopened.camera, reopened.selection.clone());
let list = reopened.execute("material.list", &json!({})).unwrap();
let material = list["materials"].as_array().unwrap().iter().find(|m| m["name"] == "Synthetic").unwrap();
assert_eq!(material["physical_card"], out["physical_card"]);
assert!(Arc::ptr_eq(&doc, &reopened.doc));
assert_eq!(before, (reopened.revision, reopened.undo.len(), reopened.camera, reopened.selection.clone()));
}
#[test]
fn omission_retains_null_clears_and_unrelated_material_edits_retain_cards() {
let mut s = fixture();
s.execute("material.create", &json!({"name":"With card","density_g_cm3":0.7,"physical_card":card()})).unwrap();
let original = s.doc.user_materials[1].physical_card.clone();
for p in [json!({"density_g_cm3":0.9}), json!({"color":"#112233"}), json!({"name":"Renamed"})] {
let mut p = p;
p["material"] = json!(s.doc.user_materials[1].name);
s.execute("material.edit", &p).unwrap();
assert_eq!(s.doc.user_materials[1].physical_card, original);
}
let mut only_card = Document::default();
only_card.user_materials.push(s.doc.user_materials[1].clone());
let error = solidcraft_io::catalog::Catalog::from_document(&only_card, "user.fixture", "Original", Default::default())
.and_then(|c| c.in_format(solidcraft_io::catalog::V1_FORMAT))
.unwrap_err();
assert!(error.to_string().contains("engineering cards"));
s.execute("material.edit", &json!({"material":"Renamed","physical_card":null})).unwrap();
assert!(s.doc.user_materials[1].physical_card.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.user_materials[1].physical_card, original);
}
#[test]
fn hostile_cards_refuse_atomically_and_saved_card_admission_is_not_bypassed() {
let mut s = fixture();
let mut bad = card();
bad["properties"][1]["data"]["model"]["poisson_ratio"] = json!(0.6);
let mut oversized = card();
oversized["sources"] = json!(vec![json!({}); 33]);
let mut unknown = card();
unknown["unrecognized"] = json!(true);
let mut missing = card();
missing["properties"][0]["source_id"] = json!("missing");
let doc = s.doc.clone();
let before = (s.undo.len(), s.redo.len(), s.revision, s.camera, s.selection.clone());
for candidate in [bad, oversized, unknown, missing, json!(true)] {
assert!(s.execute("material.edit", &json!({"material":"Synthetic","physical_card":candidate})).is_err());
assert!(s.execute("material.create", &json!({"name":"Refused","density_g_cm3":1,"physical_card":candidate})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.undo.len(), s.redo.len(), s.revision, s.camera, s.selection.clone()));
let mut saved = serde_json::to_value(&*s.doc).unwrap();
saved["user_materials"][0]["physical_card"] = candidate;
assert!(Document::from_json(&saved.to_string()).is_err());
assert!(serde_json::from_value::<Document>(saved).is_err());
}
// Large finite SI constants are accepted only in this typed card, not geometry.
let mut saved = serde_json::to_value(&*s.doc).unwrap();
saved["user_materials"][0]["physical_card"] = card();
assert!(Document::from_json(&saved.to_string()).is_ok());
saved["hostile_numeric_probe"] = json!(12.25e9);
assert!(Document::from_json(&saved.to_string()).unwrap_err().to_string().contains("number"));
}
#[test]
fn configurations_rename_and_paste_new_keep_design_owned_material_semantics() {
let mut s = Session::default();
let part = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":10,"height":10})).unwrap();
s.execute("material.create", &json!({"name":"Synthetic","density_g_cm3":0.8,"physical_card":card()})).unwrap();
s.execute("config.configure", &json!({"materials":["Specimen"]})).unwrap();
s.execute("config.row", &json!({"name":"Candidate","values":{"material:Specimen":"Synthetic"}})).unwrap();
s.execute("config.activate", &json!({"row":"Candidate"})).unwrap();
s.execute("component.activate", &json!({"component":0})).unwrap();
s.execute("component.paste_new", &json!({"component":part,"translate":[30,0,0]})).unwrap();
assert_eq!(s.doc.user_materials.len(), 1);
assert_eq!(s.doc.materials.values().filter(|m| *m == "Synthetic").count(), 2);
let original = s.doc.user_materials[0].physical_card.clone();
s.execute("material.edit", &json!({"material":"Synthetic","name":"Renamed candidate"})).unwrap();
s.execute("config.activate", &json!({"row":"Default"})).unwrap();
s.execute("config.activate", &json!({"row":"Candidate"})).unwrap();
let loaded = Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(loaded.user_materials[0].physical_card, original);
assert_eq!(loaded.materials.values().filter(|m| *m == "Renamed candidate").count(), 2);
assert!(loaded.configs.rows.iter().any(|r| r.cells.iter().any(|c| c == "Renamed candidate")));
}
#[test]
fn format21_materials_without_cards_keep_their_optical_look() {
let s = fixture();
let mut saved = serde_json::to_value(&*s.doc).unwrap();
saved["format"] = json!("solidcraft/21");
let loaded = Document::from_json(&saved.to_string()).unwrap();
assert_eq!(loaded.user_materials, s.doc.user_materials);
assert!(loaded.user_materials[0].physical_card.is_none());
assert_eq!(loaded.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
}
#[test]
fn scalar_catalog_refuses_card_export_without_creating_or_replacing_files_or_history() {
struct ProbeFile(std::path::PathBuf);
impl Drop for ProbeFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let file = ProbeFile(std::env::temp_dir().join(format!("solidcraft-card-refusal-{}-{unique}.json", std::process::id())));
let mut s = Session::default();
s.execute("material.create", &json!({"name":"Card only","density_g_cm3":0.8,"physical_card":card()})).unwrap();
s.mark_saved();
let doc = s.doc.clone();
let history = (s.undo.len(), s.redo.len(), s.revision);
for existing in [false, true] {
if existing {
std::fs::write(&file.0, b"keep existing catalog").unwrap();
}
for overwrite in [false, true] {
// The default writer is rich v2. Request scalar v1 explicitly so this
// regression still exercises refusal rather than a valid lossless copy.
let error =
s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite,"format":solidcraft_io::catalog::V1_FORMAT})).unwrap_err();
let message = error.to_string();
assert!(message.contains("catalog v1") && message.contains("engineering cards"), "{message}");
if existing {
assert_eq!(std::fs::read(&file.0).unwrap(), b"keep existing catalog");
} else {
assert!(!file.0.exists());
}
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
assert!(!s.is_dirty());
}
}
}
#[test]
fn native_container_keeps_large_si_card_values_sources_conditions_and_density_separate() {
let mut s = fixture();
s.execute("material.edit", &json!({"material":"Synthetic","physical_card":card()})).unwrap();
let before = s.execute("component.properties", &json!({})).unwrap();
let scene = product_scene::extract(&s).unwrap();
let native = solidcraft_io::write_design_container(&s.doc).unwrap();
assert!(solidcraft_io::is_design_container(&native));
let loaded = solidcraft_io::read_design(&native).unwrap();
assert_eq!(loaded.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
assert_eq!(loaded.user_materials, s.doc.user_materials);
let mut reopened = Session::new(loaded);
assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), before);
assert_eq!(product_scene::extract(&reopened).unwrap().meshes[0].surface, scene.meshes[0].surface);
let list = reopened.execute("material.list", &json!({})).unwrap();
let material = list["materials"].as_array().unwrap().iter().find(|m| m["name"] == "Synthetic").unwrap();
assert_eq!(material["physical_card"], serde_json::to_value(s.doc.user_materials[0].physical_card.as_ref().unwrap()).unwrap());
assert_eq!(material["density_comparison"]["assigned_density_kg_m3"], 800.0);
assert_eq!(material["density_comparison"]["card_density_kg_m3"], 700.0);
assert_eq!(material["physical_validation"]["source_review_required"], json!(["fixture"]));
}
#[test]
fn format20_materials_without_cards_upgrade_to21_without_changing_their_optical_look() {
let s = fixture();
let mut saved = serde_json::to_value(&*s.doc).unwrap();
saved["format"] = json!("solidcraft/20");
let loaded = Document::from_json(&saved.to_string()).unwrap();
assert_eq!(loaded.user_materials, s.doc.user_materials);
assert!(loaded.user_materials[0].physical_card.is_none());
assert_eq!(loaded.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
}
#[test]
fn card_number_admission_preserves_metadata_without_exempting_other_material_fields() {
let s = fixture();
let mut saved = serde_json::to_value(&*s.doc).unwrap();
saved["user_materials"][0]["physical_card"] = card();
let metadata = json!({"org.example.measurement":{"value":12.25e9,"physical_card":{"untyped":12.25e9}}});
saved["metadata"] = metadata.clone();
let loaded = Document::from_json(&saved.to_string()).unwrap();
let reopened = solidcraft_io::read_design(&solidcraft_io::write_design_container(&loaded).unwrap()).unwrap();
assert_eq!(serde_json::to_value(&reopened.metadata).unwrap(), metadata);
assert_eq!(reopened.user_materials, loaded.user_materials);
// Only the exact root user-material card receives SI admission. The same key
// nested elsewhere must not exempt a hostile number from geometry limits.
for field in ["appearance", "unrecognized"] {
let mut hostile = saved.clone();
hostile["user_materials"][0][field] = json!({"physical_card":{"untyped":12.25e9}});
assert!(Document::from_json(&hostile.to_string()).unwrap_err().to_string().contains("number"));
}
saved["user_materials"][0]["density"] = json!(12.25e9);
assert!(Document::from_json(&saved.to_string()).unwrap_err().to_string().contains("number"));
}
+41 -11
View File
@@ -7,7 +7,7 @@
use serde_json::{Value, json};
use solidcraft_doc::appearance::{Look, library};
use solidcraft_doc::config::Column;
use solidcraft_doc::{DEFAULT_MATERIAL, MATERIALS, UserMaterial, valid_density};
use solidcraft_doc::{DEFAULT_MATERIAL, MATERIALS, MAX_USER_MATERIALS, UserMaterial, valid_density};
use super::CommandSpec;
use super::inspect::{color_param, look_json};
@@ -16,18 +16,19 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1), pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (opaque only, needs color/appearance): the default look. Optical edits do not change density. Assign it with material.assign",
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1), pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (opaque only, needs color/appearance): the default look. Optical edits do not change density. physical_card?: validated SI candidate record (null/omitted means unknown); card density never changes assigned density. Assign it with material.assign",
),
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). Omitted appearance/color retains the entire current look",
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). physical_card?: validated SI candidate record (omitted retains, null clears). Omitted appearance/color retains the entire current look",
),
CommandSpec::new("material.delete", "Delete Physical Material", delete).icon("delete").enabled(has_user_materials).params(
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
),
];
/// Most materials a design defines.
const MAX_USER_MATERIALS: usize = 1000;
#[cfg(test)]
#[path = "material_card_persistence_tests.rs"]
mod persistence_tests;
fn has_user_materials(s: &Session) -> std::result::Result<(), String> {
if s.doc.user_materials.is_empty() { Err("the design defines no physical materials (material.create)".into()) } else { Ok(()) }
@@ -86,8 +87,35 @@ fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
Ok(Some(Some(l)))
}
fn material_json(m: &UserMaterial) -> Value {
json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true})
pub(super) fn material_json(m: &UserMaterial) -> Result<Value> {
let mut out = json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true});
if let Some(record) = &m.physical_card {
let card = record.card();
out["physical_card"] = json!(record);
out["physical_validation"] = json!(card.validate()?);
let measured = card.properties.iter().find_map(|p| match p.data {
solidcraft_doc::physical_material::PropertyData::Density { kg_m3 } => Some(kg_m3),
_ => None,
});
if let Some(measured) = measured {
let assigned = m.density * 1000.0;
let relative_difference = (assigned - measured).abs() / assigned.max(measured);
out["density_comparison"] = json!({"assigned_density_kg_m3":assigned,"card_density_kg_m3":measured,
"relative_difference":relative_difference,"matches_within_relative_tolerance":relative_difference <= 1e-9,
"relative_tolerance":1e-9});
}
}
Ok(out)
}
fn physical_card(cmd: &str, p: &Value) -> Result<Option<Option<solidcraft_doc::physical_material::ValidatedCard>>> {
match p.get("physical_card") {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(value) => {
solidcraft_doc::physical_material::ValidatedCard::from_value(value).map(|card| Some(Some(card))).map_err(|e| bad(cmd, e.to_string()))
}
}
}
/// The index of the design material `material` names.
@@ -106,8 +134,9 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc.user_materials.len() >= MAX_USER_MATERIALS {
return Err(bad(cmd, format!("a design defines at most {MAX_USER_MATERIALS} materials")));
}
let material = UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name };
let out = material_json(&material);
let material =
UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name, physical_card: physical_card(cmd, p)?.flatten() };
let out = material_json(&material)?;
s.doc_mut().user_materials.push(material);
Ok(out)
}
@@ -173,8 +202,9 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
l
}),
};
let material = UserMaterial { name: name.clone(), density, appearance };
let mut out = material_json(&material);
let physical_card = physical_card(cmd, p)?.unwrap_or(old.physical_card);
let material = UserMaterial { name: name.clone(), density, appearance, physical_card };
let mut out = material_json(&material)?;
if let Some(m) = s.doc_mut().user_materials.get_mut(i) {
*m = material;
}
+220 -2
View File
@@ -89,7 +89,11 @@ fn invalid_optical_requests_refuse_atomically_and_scalar_export_cannot_lose_them
"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
)
.unwrap();
assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err());
assert!(
solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default())
.and_then(|c| c.in_format(solidcraft_io::catalog::V1_FORMAT))
.is_err()
);
let mut invalid: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap();
invalid["appearances"]["bodies"]["Specimen"]["opacity"] = json!(0.5);
assert!(crate::doc::Document::from_json(&invalid.to_string()).is_err());
@@ -115,7 +119,11 @@ fn physical_default_look_and_face_override_keep_independent_optical_meaning() {
assert!(scene.meshes[0].surface.is_some());
assert!(scene.meshes[0].faces.iter().any(|f| f.color == [0, 128, 160] && f.surface.is_none()));
assert_eq!(s.doc.user_materials[0].density, 0.7);
assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err());
assert!(
solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default())
.and_then(|c| c.in_format(solidcraft_io::catalog::V1_FORMAT))
.is_err()
);
}
#[test]
@@ -144,3 +152,213 @@ fn independent_component_copy_keeps_its_opaque_response_after_source_edits_and_r
let reopened = crate::doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(reopened.appearances.components[&copied], original);
}
fn pack_session() -> Session {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":20,"height":30,"body_name":"Swatch"})).unwrap();
s.execute("material.assign", &json!({"bodies":["Swatch"],"material":"Aluminum"})).unwrap();
s
}
#[test]
fn copied_recipe_id_labels_are_inert_and_explicit_recipe_requests_stay_strict() {
let mut s = fixture();
for name in ["starter.metal.gold-warm", "starter.glass.clear-soda-lime"] {
for pbr in [serde_json::Value::Null, json!({"metallic":0.0,"roughness":0.73,"dielectric_ior":1.37})] {
s.execute("appearance.assign", &json!({"bodies":["Specimen"],"name":name,"color":[31,61,91],"opacity":1.0,"pbr":pbr})).unwrap();
let look = &s.doc.appearances.bodies["Specimen"];
assert_eq!(look.name, name);
assert_eq!(look.color, [31, 61, 91]);
assert_eq!(serde_json::to_value(look.pbr).unwrap(), pbr);
assert_eq!(product_scene::extract(&s).unwrap().meshes[0].surface.is_some(), !pbr.is_null());
}
}
let before = s.doc.clone();
let history = (s.undo.len(), s.redo.len(), s.revision);
for params in [
json!({"bodies":["Specimen"],"name":"Owned label"}),
json!({"bodies":["Specimen"],"name":"Owned label","appearance":"starter.metal.gold-warm","color":[31,61,91]}),
json!({"bodies":["Specimen"],"appearance":"starter.glass.clear-soda-lime","color":[31,61,91],"pbr":null}),
json!({"bodies":["Specimen"],"name":"x".repeat(129),"color":[31,61,91]}),
] {
assert!(s.execute("appearance.assign", &params).is_err());
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
}
s.execute("appearance.assign", &json!({"bodies":["Specimen"],"appearance":"starter.metal.gold-warm"})).unwrap();
assert_eq!(s.doc.appearances.bodies["Specimen"].name, "Metal - Warm Reflective Gold");
assert_eq!(s.doc.appearances.bodies["Specimen"].pbr.unwrap().metallic(), 1.0);
}
#[test]
fn pack_listing_keeps_legacy_rows_and_labels_unavailable_recipes_without_mutation() {
let mut s = pack_session();
let doc = s.doc.clone();
let undo = s.undo.len();
let reply = s.execute("appearance.library", &json!({})).unwrap();
let rows = reply["appearances"].as_array().unwrap();
// Compatibility fixture: exact pre-pack scalar library, independent of the
// separate six PBR finishes (#1366), which may also have no catalog IDs.
let legacy: [(&str, [u8; 3], f64); 20] = [
("Aluminum - Satin", [204, 208, 214], 1.0),
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
("Steel - Satin", [158, 164, 172], 1.0),
("Stainless Steel - Brushed", [186, 191, 198], 1.0),
("Cast Iron", [104, 104, 110], 1.0),
("Brass - Polished", [208, 172, 88], 1.0),
("Copper", [204, 122, 80], 1.0),
("Titanium", [150, 148, 158], 1.0),
("ABS - White", [236, 236, 230], 1.0),
("ABS - Black", [36, 36, 38], 1.0),
("ABS - Red", [196, 40, 40], 1.0),
("ABS - Blue", [40, 90, 186], 1.0),
("ABS - Yellow", [236, 196, 40], 1.0),
("ABS - Green", [52, 150, 80], 1.0),
("Rubber - Black", [28, 28, 28], 1.0),
("Glass - Clear", [190, 220, 228], 0.25),
("Glass - Smoked", [90, 96, 104], 0.45),
("Wood - Oak", [190, 146, 92], 1.0),
("Paint - Gloss White", [246, 246, 244], 1.0),
];
assert_eq!(solidcraft_doc::appearance::LIBRARY, legacy);
for (name, color, opacity) in legacy {
let matching: Vec<_> = rows.iter().filter(|row| row["name"] == name).collect();
assert_eq!(matching.len(), 1, "legacy appearance must occur exactly once: {name}");
let row = matching[0];
assert_eq!(row["color"], format!("#{:02x}{:02x}{:02x}", color[0], color[1], color[2]));
assert_eq!(row["opacity"], opacity);
assert!(row.get("id").is_none() && row.get("pbr").is_none(), "legacy scalar meaning changed: {name}");
}
assert_eq!(rows.len(), 60);
assert_eq!(rows.iter().filter(|row| row["available"] != false).count(), 54);
assert_eq!(rows.iter().filter(|row| row["available"] != false && row["opacity"] == 1.0).count(), 52);
assert_eq!(rows.iter().filter(|row| row["opacity"] != 1.0).count(), 2);
assert_eq!(rows.iter().filter(|v| v.get("id").is_some()).count(), 40);
assert_eq!(rows.iter().filter(|v| v["available"] == false).count(), 6);
for row in rows.iter().filter(|v| v["category"] == "glass") {
assert_eq!(row["available"], false);
assert_eq!(row["opacity"], 1.0);
assert!(row["pbr"].is_null());
assert_eq!(row["requirements"], json!(["refraction", "certified_thin_or_closed"]));
}
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(s.undo.len(), undo);
}
#[test]
fn opaque_pack_uses_normal_assignment_undo_and_preserves_engineering_properties() {
let mut s = pack_session();
let before = s.doc.clone();
let undo = s.undo.len();
let density = s.doc.density("Swatch");
let physical = s.execute("component.properties", &json!({"component":"root"})).unwrap()["total"].clone();
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":"starter.metal.gold-warm"})).unwrap();
let look = s.doc.appearances.bodies.get("Swatch").unwrap();
assert_eq!(look.pbr.unwrap().metallic(), 1.0);
assert_eq!(look.opacity, 1.0);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.doc.density("Swatch"), density);
assert_eq!(s.execute("component.properties", &json!({"component":"root"})).unwrap()["total"], physical);
let assigned = s.doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc, *before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(*s.doc, *assigned);
}
#[test]
fn unavailable_glass_and_invalid_opaque_override_refuse_atomically() {
let mut s = pack_session();
let doc = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
for extra in [json!({}), json!({"color":"#ffffff"}), json!({"opacity":0.25,"pbr":null})] {
let mut params = json!({"bodies":["Swatch"],"appearance":"starter.glass.clear-soda-lime"});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
assert!(s.execute("appearance.assign", &params).unwrap_err().to_string().contains("refraction"));
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.is_dirty()));
}
assert!(s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":"starter.wood.oak-honey","opacity":0.4})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.is_dirty()));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cold_native_reopen_keeps_owned_opaque_values_without_pack_metadata_or_assets() {
let mut s = pack_session();
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":"starter.wood.walnut-dark"})).unwrap();
let look = s.doc.appearances.bodies.get("Swatch").unwrap().clone();
let density = s.doc.density("Swatch");
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("solidcraft-pack-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("opaque.solidcraft");
s.execute("file.save", &json!({"path":path.to_str().unwrap()})).unwrap();
let mut reopened = Session::default();
reopened.execute("doc.open", &json!({"path":path.to_str().unwrap()})).unwrap();
assert_eq!(reopened.doc.appearances.bodies.get("Swatch"), Some(&look));
assert_eq!(reopened.doc.density("Swatch"), density);
let json = reopened.doc.to_json();
assert!(!json.contains("starter.wood.walnut-dark") && !json.contains("starter_pack.rs"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn starter_selectors_keep_final_validation_and_custom_names_stay_inert() {
let mut s = pack_session();
let preset = solidcraft_doc::appearance_pack::find("starter.wood.oak-honey").unwrap();
let doc = s.doc.clone();
let history = (s.undo.len(), s.redo.len(), s.revision);
for selector in [preset.id, preset.name] {
for opacity in [json!(0.5), json!(2.0), json!("1"), json!(null)] {
assert!(s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":selector,"opacity":opacity})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
}
}
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":preset.id,"pbr":null,"opacity":0.4})).unwrap();
assert!(s.doc.appearances.bodies["Swatch"].pbr.is_none());
assert_eq!(s.doc.appearances.bodies["Swatch"].opacity, 0.4);
// A copied name is inert; it is not a request to select the registered recipe.
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"name":preset.name,"color":"#123456","opacity":0.6})).unwrap();
let look = &s.doc.appearances.bodies["Swatch"];
assert_eq!((look.name.as_str(), look.color, look.opacity, look.pbr), (preset.name, [18, 52, 86], 0.6, None));
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":preset.name})).unwrap();
assert_eq!(s.doc.appearances.bodies["Swatch"], preset.opaque_look().unwrap());
}
#[test]
fn starter_names_and_ids_share_admission_before_all_overrides() {
use crate::doc::appearance_pack::{Recipe, STARTER_PACK};
let mut s = pack_session();
for preset in &STARTER_PACK {
for selector in [preset.id, preset.name] {
if matches!(preset.recipe, Recipe::Glass(_)) {
for overrides in [
json!({}),
json!({"color":"#aabbcc"}),
json!({"color":"#aabbcc","opacity":0.5,"pbr":null}),
json!({"color":"#aabbcc","pbr":{"metallic":0.0,"roughness":0.5,"dielectric_ior":1.5}}),
] {
let before = s.doc.clone();
let history = (s.undo.len(), s.redo.len(), s.revision);
let mut params = overrides;
params["bodies"] = json!(["Swatch"]);
params["appearance"] = json!(selector);
let error = s.execute("appearance.assign", &params).unwrap_err().to_string();
assert!(error.contains("refraction"), "{selector}: {error}");
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
}
} else {
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":selector,"color":[31,61,91]})).unwrap();
let look = &s.doc.appearances.bodies["Swatch"];
assert_eq!(look.name, preset.name);
assert_eq!(look.color, [31, 61, 91]);
assert_eq!(look.pbr, preset.opaque_look().unwrap().pbr);
}
}
}
}
+7 -2
View File
@@ -10,11 +10,15 @@ use super::CommandSpec;
use crate::params::bad;
use crate::{Result, Session};
mod clear_coat;
mod environment;
mod jobs;
mod textures;
mod transmission;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only()
.params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"),
.params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false; progressive?: false; samples?: 1..4096 (64); max_depth?: 1..16 (8); seed?: u32 (0). raytrace?: {dielectrics: up to eight runtime body overrides, clear_coats: up to 64 runtime opaque coat overrides, textures: bounded body mappings (docs/render-runtime-textures.md)} (see docs/render-transmission.md); lens?: {aperture_radius_mm, focus_distance_mm} (see docs/render-thin-lens.md). environment?: {kind: hdr, path, assume_linear_srgb?} OR {kind: sky, gradient?}, with rotation_rad/intensity (docs/render-environment.md); lighting?: {sun?: {direction,angular_radius_rad,radiance}, environment: [r,g,b], area_lights: [{center,u,v,radiance}]} and tone_map?: reinhard|aces_fitted (docs/render-studio-lighting.md); denoise?: bool (false), native GPU final edge-aware filter (docs/render-denoise.md). Sampling, exposure, transmission, clear-coat, texture, lens, lighting, tone-map and denoise controls require progressive rendering. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"),
CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("render_image")
.params("Same request as render.preflight. Captures an immutable product scene and queues a bounded image job. Returns its session-scoped job identity, captured scene_generation and progress. Does not change the design. See docs/render-jobs.md"),
CommandSpec::new("render.status", "Render Progress", jobs::status).icon("list").noundo().doc_only()
@@ -45,7 +49,8 @@ fn scene(s: &mut Session, params: &Value) -> Result<Value> {
let bodies: Vec<_> = scene
.meshes
.iter()
.map(|m| json!({"name":m.name,"triangles":m.mesh.triangles.len(),"color":m.color,"opacity":m.opacity,"face_appearances":m.faces.len()}))
.enumerate()
.map(|(ordinal,m)| json!({"ordinal":ordinal,"name":m.name,"triangles":m.mesh.triangles.len(),"color":m.color,"opacity":m.opacity,"face_appearances":m.faces.len()}))
.collect();
Ok(
json!({"bodies":bodies,"bounds":if scene.bounds.is_empty() { Value::Null } else { json!({"min":scene.bounds.min,"max":scene.bounds.max}) },"overlays":false}),
@@ -0,0 +1,87 @@
//! Request-only coat overrides on immutable captured geometry; no saved appearance changes.
use crate::{Result, params::bad};
use serde::Deserialize;
use solidcraft_geom::Vec3;
use solidcraft_render::{
ProductScene,
clear_coat::ClearCoat,
surface::{OpaqueSurface, srgb8_to_linear},
};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Override {
body_index: usize,
body_name: String,
weight: f64,
roughness: f64,
ior: f64,
}
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
if overrides.len() > 64 {
return Err(bad(command, "at most 64 runtime clear-coat overrides are supported"));
}
let mut ordinals = std::collections::BTreeSet::new();
for input in overrides {
if input.body_name.is_empty()
|| input.body_name.len() > 1024
|| input.body_name.chars().any(char::is_control)
|| !ordinals.insert(input.body_index)
{
return Err(bad(command, "clear coats require bounded names and distinct captured body ordinals"));
}
let coat = ClearCoat::new(input.weight, input.roughness, input.ior).map_err(|e| bad(command, e.to_string()))?;
let body = scene
.meshes
.get_mut(input.body_index)
.filter(|m| m.name == input.body_name)
.ok_or_else(|| bad(command, "clear-coat body ordinal and exact name must both match render.scene"))?;
// A zero-weight override is a true no-op, including legacy appearances.
if coat.weight() == 0.0 {
continue;
}
if body.transmission.is_some() || body.faces.iter().any(|f| f.transmission.is_some()) {
return Err(bad(command, "clear coat and transmission cannot share a body"));
}
let surface = |color: [u8; 3], existing: Option<OpaqueSurface>| -> Result<OpaqueSurface> {
let [r, g, b] = color.map(srgb8_to_linear);
let base =
existing.map(Ok).unwrap_or_else(|| OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 0.5, 1.5)).map_err(|e| bad(command, e.to_string()))?;
Ok(base.with_clear_coat(coat))
};
body.surface = Some(surface(body.color, body.surface)?);
for face in &mut body.faces {
face.surface = Some(surface(face.color, face.surface)?);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn coat_is_runtime_only_and_validates_exact_body_and_values() {
let mut s = crate::Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":2,"body_name":"Housing"})).unwrap();
let saved = s.doc.to_json();
let original = crate::product_scene::extract(&s).unwrap();
let input = json!({"body_index":0,"body_name":"Housing","weight":1,"roughness":0.12,"ior":1.5});
let mut scene = original.clone();
apply(&mut scene, &[serde_json::from_value(input.clone()).unwrap()], "render.start").unwrap();
assert!(scene.meshes[0].surface.unwrap().clear_coat().is_some());
assert!(std::sync::Arc::ptr_eq(&scene.meshes[0].mesh, &original.meshes[0].mesh));
assert_eq!(s.doc.to_json(), saved);
assert!(crate::product_scene::extract(&s).unwrap().meshes[0].surface.is_none_or(|s| s.clear_coat().is_none()));
for (key, value) in
[("weight", json!(-0.1)), ("roughness", json!(0)), ("ior", json!(4)), ("body_index", json!(1)), ("body_name", json!("Wrong"))]
{
let mut bad_input = input.clone();
bad_input[key] = value;
assert!(apply(&mut original.clone(), &[serde_json::from_value(bad_input).unwrap()], "render.start").is_err());
}
let duplicate = serde_json::from_value::<Vec<Override>>(json!([input, input])).unwrap();
assert!(apply(&mut original.clone(), &duplicate, "render.start").is_err());
}
}
@@ -0,0 +1,48 @@
//! Runtime environment inputs. Captures bytes through the caller's scoped VFS.
use crate::{Result, params::bad};
use serde::Deserialize;
use solidcraft_render::lighting::{
MAX_HDR_BYTES,
environment::{EnvironmentResource, EnvironmentSource, SkyGradient},
};
use std::sync::Arc;
fn unit() -> f64 {
1.0
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum Input {
Hdr {
path: String,
#[serde(default)]
rotation_rad: f64,
#[serde(default = "unit")]
intensity: f64,
#[serde(default)]
assume_linear_srgb: bool,
},
Sky {
#[serde(default)]
gradient: SkyGradient,
#[serde(default)]
rotation_rad: f64,
#[serde(default = "unit")]
intensity: f64,
},
}
impl Input {
pub(super) fn capture(&self, command: &str) -> Result<EnvironmentResource> {
let source = match self {
Self::Hdr { path, rotation_rad, intensity, assume_linear_srgb } => {
if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) {
return Err(bad(command, "bounded HDR path required"));
}
let bytes = solidcraft_io::vfs::read(path, MAX_HDR_BYTES).map_err(|e| bad(command, format!("could not capture HDR: {e}")))?;
EnvironmentSource::hdr(bytes, *rotation_rad, *intensity, *assume_linear_srgb)
}
Self::Sky { gradient, rotation_rad, intensity } => EnvironmentSource::sky(*gradient, *rotation_rad, *intensity),
}
.map_err(|e| bad(command, e.to_string()))?;
Ok(Arc::new(source))
}
}
+98
View File
@@ -23,6 +23,94 @@ fn finish(host: &CpuHost) {
panic!("cooperative job did not finish");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn additional_executor_routes_by_capability_and_survives_document_adoption() {
use solidcraft_render::job::{Admission, CancelToken, ImageExecutor, ImageHost, ImageRequest, ImageTask, ImageWork, JobOptions};
struct Progressive;
impl ImageExecutor for Progressive {
fn descriptor(&self) -> solidcraft_render::job::Result<solidcraft_render::ExecutorDescriptor> {
let mut descriptor = CpuHost::cooperative().descriptor()?;
descriptor.id = solidcraft_render::ExecutorId::new("test-progressive").unwrap();
descriptor.profiles[0].features.push(solidcraft_render::RenderFeature::Progressive);
descriptor.profiles[0].features.push(solidcraft_render::RenderFeature::Hdri);
Ok(descriptor)
}
fn admit(
&self,
scene: &solidcraft_render::ProductScene,
request: &ImageRequest,
options: JobOptions,
) -> solidcraft_render::job::Result<Admission> {
options.validate()?;
// admit_geometry counts source geometry only; this executor renders through the
// CPU task, which ignores the environment and textures, so budget its pixels and
// PNG with the CPU estimate for the same frame.
solidcraft_render::job::admit(scene, &ImageRequest { environment: None, textures: None, ..request.clone() })
}
fn begin(
&self,
scene: solidcraft_render::ProductScene,
request: ImageRequest,
_: JobOptions,
cancel: CancelToken,
) -> solidcraft_render::job::Result<Box<dyn ImageWork>> {
Ok(Box::new(ImageTask::new(scene, request, cancel)?))
}
}
let (mut session, cpu) = session();
let additional = ImageHost::with_executor(Arc::new(Progressive)).unwrap();
session.register_render_job_host(additional.clone()).unwrap();
assert!(
session.register_render_job_host(ImageHost::with_executor(Arc::new(Progressive)).unwrap()).is_err(),
"duplicate executor IDs must be rejected"
);
let cpu_id = start(&mut session);
let request = json!({"settings":{"width":16,"height":16},"progressive":true,"samples":3});
let preflight = session.execute("render.preflight", &request).unwrap();
assert_eq!(preflight["resolved"]["executor"], "test-progressive");
assert_eq!(additional.admitted_bytes(), 0);
let mut studio = request.clone();
studio["lighting"] = json!({"environment":[1,1,1],"area_lights":[{"center":[0,0,10],"u":[2,0,0],"v":[0,-2,0],"radiance":[4,4,4]}]});
studio["tone_map"] = json!("aces_fitted");
let document = session.doc.clone();
let studio_reply = session.execute("render.preflight", &studio).unwrap();
assert_eq!(studio_reply["options"]["lighting"]["environment"], json!([1.0, 1.0, 1.0]));
assert_eq!(studio_reply["options"]["tone_map"], "aces_fitted");
assert!(Arc::ptr_eq(&document, &session.doc));
studio["environment"] = json!({"kind":"sky","intensity":0.5,"rotation_rad":0.3});
let env_reply = session.execute("render.preflight", &studio).unwrap();
assert_eq!(env_reply["environment"]["kind"], "original_gradient_sky");
assert_eq!(env_reply["environment"]["width"], 512);
assert_eq!(env_reply["environment"]["source_sha256"].as_str().unwrap().len(), 64);
assert!(env_reply["request"].get("environment").is_none());
assert!(Arc::ptr_eq(&document, &session.doc));
studio["progressive"] = json!(false);
assert!(session.execute("render.preflight", &studio).unwrap_err().to_string().contains("require progressive"));
studio["progressive"] = json!(true);
studio["lighting"]["area_lights"][0]["u"] = json!([0, 0, 0]);
assert!(session.execute("render.preflight", &studio).is_err());
let started = session.execute("render.start", &request).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let status = session.render_job_handle(id).unwrap().wait_timeout(std::time::Duration::from_secs(10)).unwrap();
assert_eq!(status.resolved.executor.as_str(), "test-progressive");
assert_eq!(status.options.samples, 3);
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Completed);
finish(&cpu);
assert_eq!(session.execute("render.status", &json!({"job":cpu_id})).unwrap()["frame"]["executor"], "cpu-product");
assert!(session.scratch().execute("render.status", &json!({"job":id})).is_err());
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})] {
let mut params = request.clone();
for (key, value) in bad.as_object().unwrap() {
params[key] = value.clone();
}
assert!(session.execute("render.start", &params).is_err());
}
}
#[test]
fn runtime_commands_preserve_document_history_and_evaluation() {
let (mut s, host) = session();
@@ -217,3 +305,13 @@ fn native_start_and_cancel_measure_cached_capture_without_timing_assertions() {
);
assert!(status.progress.phase.terminal());
}
#[test]
fn runtime_texture_controls_require_progressive_and_leave_document_unchanged() {
let (mut s, _) = session();
let doc = s.doc.clone();
let input = json!({"raytrace":{"textures":[{"body_index":0,"body_name":"No body","frame":{"origin":[0,0,0],"u":[1,0,0],"v":[0,1,0]},"normal_opengl":"not-opened.png"}]}});
let error = s.execute("render.preflight", &input).unwrap_err().to_string();
assert!(error.contains("require progressive"), "{error}");
assert!(Arc::ptr_eq(&doc, &s.doc));
}
+95 -16
View File
@@ -14,14 +14,50 @@ struct Start {
settings: Option<RenderSetup>,
transparent_background: bool,
replace: bool,
progressive: bool,
samples: Option<u32>,
max_depth: Option<u32>,
seed: Option<u32>,
raytrace: super::transmission::Raytrace,
lens: Option<solidcraft_render::job::ThinLens>,
lighting: Option<solidcraft_render::job::StudioLighting>,
tone_map: Option<solidcraft_render::job::ToneMap>,
environment: Option<super::environment::Input>,
denoise: bool,
}
fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcraft_render::ProductScene, ImageRequest, Value)> {
struct Prepared {
input: Start,
scene: solidcraft_render::ProductScene,
request: ImageRequest,
reply: Value,
executor: solidcraft_render::ExecutorId,
options: solidcraft_render::job::JobOptions,
}
fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
let input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
if input.setup.is_some() && input.settings.is_some() {
return Err(bad(command, "choose either setup or settings"));
}
let scene = crate::product_scene::extract(s)?;
let mut scene = crate::product_scene::extract(s)?;
if !input.progressive
&& (!input.raytrace.dielectrics.is_empty()
|| !input.raytrace.clear_coats.is_empty()
|| !input.raytrace.textures.is_empty()
|| input.lens.is_some()
|| input.lighting.is_some()
|| input.tone_map.is_some()
|| input.environment.is_some()
|| input.denoise)
{
return Err(bad(
command,
"runtime transmission, clear-coat, texture, lens, environment, lighting, tone-map and denoise controls require progressive rendering",
));
}
super::transmission::apply(&mut scene, &input.raytrace.dielectrics, command)?;
super::clear_coat::apply(&mut scene, &input.raytrace.clear_coats, command)?;
let settings = if let Some(name) = &input.setup {
s.doc.render.setups.get(name).cloned().ok_or_else(|| bad(command, "no saved render setup with that name"))?
} else if let Some(settings) = &input.settings {
@@ -36,12 +72,38 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcr
settings
};
settings.validate().map_err(|e| bad(command, e.to_string()))?;
if settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 {
return Err(bad(command, "exposure and environment controls are not supported by the R4a CPU image executor"));
if settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 {
return Err(bad(command, "environment controls are not supported by the installed image executors"));
}
let host = s.render_job_host().ok_or_else(|| bad(command, "no image host is installed"))?;
if !host.is_available() {
return Err(bad(command, "image host is unavailable"));
if !input.progressive && (input.samples.is_some() || input.max_depth.is_some() || input.seed.is_some() || settings.exposure_ev != 0.0) {
return Err(bad(command, "sampling and exposure controls require progressive rendering"));
}
let mut options = solidcraft_render::job::JobOptions::default();
if let Some(samples) = input.samples {
options.samples = samples;
}
if let Some(depth) = input.max_depth {
options.max_depth = depth;
}
if let Some(seed) = input.seed {
options.seed = seed;
}
options.exposure_ev = settings.exposure_ev;
options.lens = input.lens;
options.lighting = input.lighting;
options.tone_map = input.tone_map.unwrap_or_default();
options.denoise = input.denoise;
options.validate().map_err(job_error)?;
let environment = input.environment.as_ref().map(|source| source.capture(command)).transpose()?;
let mut features = scene.required_features();
if environment.is_some() {
features.push(solidcraft_render::RenderFeature::Hdri);
}
if input.progressive {
features.push(solidcraft_render::RenderFeature::Progressive);
if scene.has_transmission() {
features.push(solidcraft_render::RenderFeature::Refraction);
}
}
let resolved = solidcraft_render::resolve_request(
&[s.render_backend()],
@@ -52,33 +114,50 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcr
width: u64::from(settings.width),
height: u64::from(settings.height),
format: Some(settings.format),
features: scene.required_features(),
features,
policy: Default::default(),
},
)
.map_err(|e| bad(command, e.to_string()))?;
if resolved.executor.as_str() != "cpu-product" {
return Err(bad(command, "resolved executor is not installed as an image job host"));
if options.denoise && resolved.executor.as_str() != "gpu-path" {
return Err(bad(command, "denoise requires the native gpu-path executor"));
}
let host = s.render_jobs.host_for(&resolved.executor).ok_or_else(|| bad(command, "resolved executor is not installed as an image job host"))?;
if !host.is_available() {
return Err(bad(command, "image host is unavailable"));
}
let request = ImageRequest {
environment,
camera: settings.camera,
width: settings.width,
height: settings.height,
background: settings.background,
transparent_background: input.transparent_background,
textures: super::textures::capture(&scene, &input.raytrace.textures, command)?,
};
let admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
let reply = json!({"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":"sorted_alpha_approximation","refraction":false});
Ok((input, scene, request, reply))
let admission = host.admit(&scene, &request, options).map_err(job_error)?;
let refraction = input.progressive && scene.has_transmission();
let reply = json!({"resolved":resolved,"admission":admission,"request":request,"textures":request.textures.as_ref().map(|resources|resources.summary()),"scene_generation":s.revision,"host":host.kind(),"transparency":if refraction {"physical_transmission"} else if input.progressive {"opaque_only"} else {"sorted_alpha_approximation"},"options":options,"environment":request.environment.as_ref().map(|source|source.info()),"refraction":refraction,"dielectric_boundaries": if resolved.executor.as_str() == "gpu-path" {vec!["thin_sheet","closed_box","closed_mesh"]} else {Vec::<&str>::new()}});
let mut reply = reply;
if options.denoise {
reply["denoising"] = json!({
"method":"edge_aware_atrous", "passes":3, "steps":[1,2,4],
"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",
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
});
}
Ok(Prepared { input, scene, request, reply, executor: resolved.executor, options })
}
pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
prepare(s, params, "render.preflight").map(|(_, _, _, reply)| reply)
prepare(s, params, "render.preflight").map(|prepared| prepared.reply)
}
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
let (input, scene, request, _) = prepare(s, params, "render.start")?;
let status = s.render_jobs.start(s.revision, scene, request, input.replace).map_err(job_error)?;
let p = prepare(s, params, "render.start")?;
let status = s.render_jobs.start(s.revision, p.scene, p.request, p.input.replace, &p.executor, p.options).map_err(job_error)?;
Ok(json!(status))
}
+106
View File
@@ -0,0 +1,106 @@
//! Bounded immutable file capture; decoding is deferred to the admitted worker.
use crate::{Result, params::bad};
use serde::Deserialize;
use solidcraft_render::{
ProductScene,
job::textures::{MAX_TEXTURE_BINDINGS, MAX_TEXTURE_SOURCE_BYTES, PlanarFrame, RuntimeTextures, TextureBinding, TextureMap},
texture_input::MapRole,
};
use std::sync::Arc;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Override {
body_index: usize,
body_name: String,
frame: PlanarFrame,
#[serde(default)]
base_color_srgb: Option<String>,
#[serde(default)]
normal_opengl: Option<String>,
#[serde(default = "normal_strength")]
normal_strength: f64,
}
fn normal_strength() -> f64 {
1.0
}
fn load(path: &str, role: MapRole, remaining: &mut u64, command: &str) -> Result<TextureMap> {
if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) {
return Err(bad(command, "texture path must be a bounded local path"));
}
let bytes = solidcraft_io::vfs::read(path, *remaining as usize).map_err(|e| bad(command, format!("could not capture texture: {e}")))?;
if bytes.is_empty() {
return Err(bad(command, "texture file must not be empty"));
}
let map = TextureMap::new(bytes, role).map_err(|e| bad(command, e.to_string()))?;
*remaining = remaining.checked_sub(map.retained_bytes() as u64).ok_or_else(|| bad(command, "encoded texture capacity exceeds budget"))?;
Ok(map)
}
pub(super) fn capture(scene: &ProductScene, overrides: &[Override], command: &str) -> Result<Option<Arc<RuntimeTextures>>> {
if overrides.is_empty() {
return Ok(None);
}
if overrides.len() > MAX_TEXTURE_BINDINGS {
return Err(bad(command, "at most 16 runtime texture bodies are supported"));
}
let mut remaining = MAX_TEXTURE_SOURCE_BYTES;
let mut bindings = Vec::new();
for input in overrides {
input.frame.validate().map_err(|e| bad(command, e.to_string()))?;
// Refuse stale identity or unsupported optics before touching any file.
let body = scene
.meshes
.get(input.body_index)
.filter(|b| b.name == input.body_name)
.ok_or_else(|| bad(command, "texture ordinal and name must both match render.scene"))?;
if body.transmission.is_some() || body.opacity != 1.0 || body.faces.iter().any(|f| f.transmission.is_some() || f.opacity != 1.0) {
return Err(bad(command, "runtime maps support opaque bodies only"));
}
let base_color = input.base_color_srgb.as_ref().map(|p| load(p, MapRole::BaseColor, &mut remaining, command)).transpose()?;
let normal = input.normal_opengl.as_ref().map(|p| load(p, MapRole::NormalOpenGl, &mut remaining, command)).transpose()?;
bindings.push(TextureBinding {
body_index: input.body_index,
body_name: input.body_name.clone(),
frame: input.frame,
normal_strength: input.normal_strength,
base_color,
normal,
});
}
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?;
resources.validate_scene(scene).map_err(|e| bad(command, e.to_string()))?;
Ok(Some(Arc::new(resources)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use solidcraft_geom::{Aabb3, Mesh};
#[test]
fn unsupported_or_stale_map_targets_fail_before_file_io() {
let scene = ProductScene {
bounds: Aabb3::EMPTY,
meshes: vec![solidcraft_render::ProductMesh {
name: "Glass".into(),
mesh: Arc::new(Mesh::default()),
color: [255; 3],
opacity: 1.,
surface: None,
transmission: Some(solidcraft_render::transmission::Transmission::new(solidcraft_geom::Vec3::ZERO, Some(1.)).unwrap()),
faces: vec![],
}],
};
let value =
json!({"body_index":0,"body_name":"Glass","frame":{"origin":[0,0,0],"u":[1,0,0],"v":[0,1,0]},"base_color_srgb":"nonexistent-image.png"});
let input: Override = serde_json::from_value(value.clone()).unwrap();
assert!(capture(&scene, &[input], "render.start").unwrap_err().to_string().contains("opaque bodies"));
let mut stale = value.clone();
stale["body_name"] = json!("Stale");
let input: Override = serde_json::from_value(stale).unwrap();
assert!(capture(&scene, &[input], "render.start").unwrap_err().to_string().contains("ordinal and name"));
let mut unknown = value;
unknown["metallic_map"] = json!("foo");
assert!(serde_json::from_value::<Override>(unknown).is_err());
}
}
@@ -0,0 +1,223 @@
//! Agreed #1659 runtime wire adapter, coordinated in Mailbox 9075.
//! Keep wire details here until the CPU contract is published. Never save them.
use crate::{Result, params::bad};
use serde::Deserialize;
use solidcraft_geom::Vec3;
use solidcraft_render::{
ProductScene,
surface::{OpaqueSurface, srgb8_to_linear},
transmission::Transmission,
};
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(super) struct Raytrace {
pub dielectrics: Vec<Override>,
pub clear_coats: Vec<super::clear_coat::Override>,
pub textures: Vec<super::textures::Override>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Override {
body_index: usize,
body_name: String,
ior: f64,
absorption_per_mm: [f64; 3],
boundary: Boundary,
thickness_mm: f64,
#[serde(default)]
shadow_transmission: Option<bool>,
}
#[derive(Deserialize)]
#[serde(try_from = "String")]
enum Boundary {
ThinSheet,
ClosedBox,
ClosedMesh,
}
impl TryFrom<String> for Boundary {
type Error = &'static str;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
match value.as_str() {
"thin_sheet" => Ok(Self::ThinSheet),
"closed_box" => Ok(Self::ClosedBox),
"closed_mesh" => Ok(Self::ClosedMesh),
_ => Err("boundary must be thin_sheet, closed_box or closed_mesh"),
}
}
}
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
if overrides.len() > 8 {
return Err(bad(command, "at most eight runtime dielectric overrides are supported"));
}
let mut ordinals = std::collections::BTreeSet::new();
for input in overrides {
if input.body_name.is_empty()
|| input.body_name.len() > 1024
|| input.body_name.chars().any(char::is_control)
|| !ordinals.insert(input.body_index)
{
return Err(bad(command, "dielectrics require bounded names and distinct captured body ordinals"));
}
let thickness = match input.boundary {
Boundary::ThinSheet => Some(input.thickness_mm),
_ => None,
};
if !matches!(input.boundary, Boundary::ThinSheet) && input.thickness_mm != 0.0 {
return Err(bad(command, "closed dielectric thickness_mm must be zero; distance comes from geometry"));
}
let [r, g, b] = input.absorption_per_mm;
let mut transmission = Transmission::new(Vec3::new(r, g, b), thickness).map_err(|e| bad(command, e.to_string()))?;
if let Some(enabled) = input.shadow_transmission {
transmission = transmission.with_shadow_transmission(enabled);
}
OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, input.ior).map_err(|e| bad(command, e.to_string()))?;
let body = scene
.meshes
.get_mut(input.body_index)
.filter(|m| m.name == input.body_name)
.ok_or_else(|| bad(command, "dielectric body ordinal and exact name must both match render.scene"))?;
if matches!(input.boundary, Boundary::ClosedBox) {
certify_box(&body.mesh, command)?;
}
// Full dielectric override replaces optical metalness in the job snapshot only.
let surface = |color: [u8; 3], existing: Option<OpaqueSurface>| {
let [r, g, b] = color.map(srgb8_to_linear);
OpaqueSurface::new(Vec3::new(r, g, b), 0.0, existing.map_or(0.5, |s| s.roughness()), input.ior).map_err(|e| bad(command, e.to_string()))
};
body.surface = Some(surface(body.color, body.surface)?);
body.transmission = Some(transmission);
for face in &mut body.faces {
face.surface = Some(surface(face.color, face.surface)?);
face.transmission = Some(transmission);
}
}
Ok(())
}
fn certify_box(mesh: &solidcraft_geom::Mesh, command: &str) -> Result<()> {
let bounds = mesh.bounds();
if mesh.triangles.is_empty()
|| !bounds.min.is_finite()
|| !bounds.max.is_finite()
|| bounds.min.x >= bounds.max.x
|| bounds.min.y >= bounds.max.y
|| bounds.min.z >= bounds.max.z
{
return Err(bad(command, "closed_box requires a nonempty axis-aligned box"));
}
for triangle in &mesh.triangles {
let points = triangle.map(|i| mesh.positions.get(i as usize).copied());
let [Some(a), Some(b), Some(c)] = points else {
return Err(bad(command, "invalid closed_box triangle"));
};
let on_plane = |x: f64, y: f64, z: f64, lo: f64, hi: f64| {
[lo, hi].into_iter().any(|plane| [x, y, z].into_iter().all(|v| v.is_finite() && (v - plane).abs() <= 1e-6))
};
if !(on_plane(a.x, b.x, c.x, bounds.min.x, bounds.max.x)
|| on_plane(a.y, b.y, c.y, bounds.min.y, bounds.max.y)
|| on_plane(a.z, b.z, c.z, bounds.min.z, bounds.max.z))
{
return Err(bad(command, "closed_box requires axis-aligned bounding planes; use GPU-only closed_mesh for other closed geometry"));
}
}
// GPU preparation additionally certifies closed, outward two-manifold topology.
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Arc;
fn fixture() -> crate::Session {
let mut s = crate::Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":2,"body_name":"Glass"})).unwrap();
s
}
fn input() -> serde_json::Value {
json!({"body_index":0,"body_name":"Glass","ior":1.5,"absorption_per_mm":[0.1,0.02,0],"boundary":"closed_box","thickness_mm":0})
}
#[test]
fn request_glass_leaves_document_geometry_and_preview_unchanged() {
let mut s = fixture();
s.execute("appearance.assign", &json!({"bodies":["Glass"],"color":"#c0e0ff","opacity":0.2})).unwrap();
let saved = s.doc.to_json();
let mut scene = crate::product_scene::extract(&s).unwrap();
let mesh = scene.meshes[0].mesh.clone();
apply(&mut scene, &[serde_json::from_value(input()).unwrap()], "render.start").unwrap();
assert!(scene.has_transmission());
assert_eq!(scene.meshes[0].opacity, 0.2);
assert!(Arc::ptr_eq(&mesh, &scene.meshes[0].mesh));
assert_eq!(scene.meshes[0].surface.unwrap().dielectric_ior(), 1.5);
assert_eq!(saved, s.doc.to_json());
let reopened = crate::Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
assert!(!crate::product_scene::extract(&reopened).unwrap().has_transmission());
assert!(
s.execute(
"appearance.assign",
&json!({"bodies":["Glass"],"color":"#ffffff",
"pbr":{"metallic":0,"roughness":0.1,"dielectric_ior":1.5,"transmission":{}}})
)
.is_err()
);
}
#[test]
fn request_glass_rejects_unknown_fractional_duplicate_and_mismatched_inputs() {
let s = fixture();
for (key, value) in [
("weight", json!(0.5)),
("ior", json!(0.9)),
("absorption_per_mm", json!([100.01, 0, 0])),
("body_index", json!(1)),
("body_name", json!("missing")),
("thickness_mm", json!(-1)),
("boundary", json!({"thin_sheet":null})),
("extra", json!(true)),
] {
let mut value_input = input();
value_input[key] = value;
let rejected = match serde_json::from_value::<Override>(value_input) {
Ok(value) => apply(&mut crate::product_scene::extract(&s).unwrap(), &[value], "render.start").is_err(),
Err(_) => true,
};
assert!(rejected, "{key}");
}
for count in [2, 9] {
let overrides = serde_json::from_value::<Vec<Override>>(json!(vec![input(); count])).unwrap();
assert!(apply(&mut crate::product_scene::extract(&s).unwrap(), &overrides, "render.start").is_err());
}
let mut thin = input();
thin["boundary"] = json!("thin_sheet");
assert!(apply(&mut crate::product_scene::extract(&s).unwrap(), &[serde_json::from_value(thin).unwrap()], "render.start").is_ok());
}
#[test]
fn closed_box_certificate_rejects_deformed_mesh_but_closed_mesh_is_explicit() {
let mut scene = crate::product_scene::extract(&fixture()).unwrap();
Arc::make_mut(&mut scene.meshes[0].mesh).positions[0] += Vec3::new(0.2, 0.3, 0.4);
assert!(apply(&mut scene.clone(), &[serde_json::from_value(input()).unwrap()], "render.start").is_err());
let mut value = input();
value["boundary"] = json!("closed_mesh");
assert!(apply(&mut scene, &[serde_json::from_value(value).unwrap()], "render.start").is_ok());
}
#[test]
fn shadow_flags_follow_boundary_defaults_and_explicit_runtime_overrides() {
let s = fixture();
let saved = s.doc.to_json();
for thin in [false, true] {
for flag in [None, Some(false), Some(true)] {
let mut value = input();
if thin {
value["boundary"] = json!("thin_sheet");
}
if let Some(flag) = flag {
value["shadow_transmission"] = json!(flag);
}
let mut scene = crate::product_scene::extract(&s).unwrap();
apply(&mut scene, &[serde_json::from_value(value).unwrap()], "render.start").unwrap();
assert_eq!(scene.meshes[0].transmission.unwrap().shadow_transmission(), flag.unwrap_or(thin));
assert_eq!(s.doc.to_json(), saved);
}
}
let mut malformed = input();
malformed["shadow_transmission"] = json!(0.5);
assert!(serde_json::from_value::<Override>(malformed).is_err());
}
}
+21 -3
View File
@@ -507,11 +507,14 @@ impl Session {
}
if let Some(executor) = &self.render_jobs.descriptor {
let mut executor = executor.clone();
if self.render_jobs.host.as_ref().is_none_or(|host| !host.is_available()) {
executor.availability = render::Availability::Unavailable { reason: "CPU image host is unavailable".into() };
}
executor.availability = image_host_availability(self.render_jobs.host.as_ref(), &executor.id);
backend.executors.push(executor);
}
for (host, descriptor) in &self.render_jobs.additional {
let mut descriptor = descriptor.clone();
descriptor.availability = image_host_availability(Some(host), &descriptor.id);
backend.executors.push(descriptor);
}
backend
}
@@ -532,6 +535,7 @@ impl Session {
if let Some(executor) = &self.render_jobs.descriptor {
backend.executors.push(executor.clone());
}
backend.executors.extend(self.render_jobs.additional.iter().map(|(_, d)| d.clone()));
backend.executors.push(executor.clone());
// Resolution validates the whole registry, including duplicate executor IDs.
let request = render::RenderRequest {
@@ -851,6 +855,7 @@ impl Session {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let host_viewport = self.render_viewport.clone();
let host_jobs = self.render_jobs.host.clone();
let additional_jobs = self.render_jobs.additional.clone();
let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true));
if live.is_none() && spec.reads_model && self.model.is_stale() {
self.refresh();
@@ -894,6 +899,9 @@ impl Session {
if self.render_jobs.host.is_none() && host_jobs.is_some() {
self.set_render_job_host(host_jobs)?;
}
for (host, _) in additional_jobs {
self.register_render_job_host(host)?;
}
if outermost && crash::enabled() {
// A failed command leaves the design as it was (restored below).
let failed = r.as_ref().err().map(ToString::to_string);
@@ -1295,3 +1303,13 @@ fn own_values(sk: &solidcraft_sketch::Sketch) -> std::collections::BTreeMap<Stri
}
values
}
/// Capabilities are fixed at registration; availability is live device/host state.
fn image_host_availability(host: Option<&render::job::ImageHost>, expected: &render::ExecutorId) -> render::Availability {
match host.map(render::job::ImageHost::descriptor) {
Some(Ok(current)) if current.id == *expected => current.availability,
Some(Ok(_)) => render::Availability::Unavailable { reason: "image executor identity changed after registration".into() },
Some(Err(error)) => render::Availability::Unavailable { reason: format!("image executor status unavailable: {error}") },
None => render::Availability::Unavailable { reason: "image host is unavailable".into() },
}
}
+2
View File
@@ -32,6 +32,7 @@ pub fn extract(s: &Session) -> Result<ProductScene> {
color: look.color,
opacity: look.opacity.clamp(0.0, 1.0),
surface: look.opaque_surface().map_err(|e| EngineError::Other(e.to_string()))?,
transmission: None,
})
})
.collect::<Result<Vec<_>>>()?;
@@ -42,6 +43,7 @@ pub fn extract(s: &Session) -> Result<ProductScene> {
color,
opacity: s.doc.model_body_opacity(body).clamp(0.0, 1.0),
surface,
transmission: None,
faces,
});
}
+55 -3
View File
@@ -30,6 +30,7 @@ pub(crate) struct Jobs {
generation: u64,
pub(crate) host: Option<CpuHost>,
pub(crate) descriptor: Option<ExecutorDescriptor>,
pub(crate) additional: Vec<(CpuHost, ExecutorDescriptor)>,
live: Vec<JobHandle>,
history: VecDeque<JobStatus>,
last_good: Option<JobHandle>,
@@ -37,7 +38,17 @@ pub(crate) struct Jobs {
impl Default for Jobs {
fn default() -> Self {
Self { owner: owner(), serial: 0, generation: 0, host: None, descriptor: None, live: Vec::new(), history: VecDeque::new(), last_good: None }
Self {
owner: owner(),
serial: 0,
generation: 0,
host: None,
descriptor: None,
additional: Vec::new(),
live: Vec::new(),
history: VecDeque::new(),
last_good: None,
}
}
}
@@ -50,10 +61,18 @@ impl Drop for Jobs {
}
impl Jobs {
pub(crate) fn host_for(&self, executor: &solidcraft_render::ExecutorId) -> Option<&CpuHost> {
if self.descriptor.as_ref().is_some_and(|d| &d.id == executor) {
return self.host.as_ref();
}
self.additional.iter().find(|(_, d)| &d.id == executor).map(|(host, _)| host)
}
pub(crate) fn scratch(&self) -> Self {
let mut jobs = Self::default();
jobs.host = self.host.clone();
jobs.descriptor = self.descriptor.clone();
jobs.additional = self.additional.clone();
jobs
}
@@ -83,9 +102,11 @@ impl Jobs {
scene: solidcraft_render::ProductScene,
request: solidcraft_render::job::ImageRequest,
replace: bool,
executor: &solidcraft_render::ExecutorId,
options: solidcraft_render::job::JobOptions,
) -> Result<JobStatus, JobError> {
self.collect()?;
let host = self.host.as_ref().ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no image host is installed"))?.clone();
let host = self.host_for(executor).ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "resolved image host is not installed"))?.clone();
if replace {
for job in self.live.drain(..) {
let mut status = job.cancel()?;
@@ -109,7 +130,7 @@ impl Jobs {
}
let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?;
let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?;
let handle = host.submit(JobId { session: self.owner, serial, generation }, revision, scene, request)?;
let handle = host.submit_with_options(JobId { session: self.owner, serial, generation }, revision, scene, request, options)?;
let status = handle.status()?;
self.serial = serial;
self.generation = generation;
@@ -176,12 +197,43 @@ impl crate::Session {
)
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
let mut jobs = Jobs::default();
jobs.additional = self.render_jobs.additional.clone();
jobs.host = host;
jobs.descriptor = descriptor;
self.render_jobs = jobs;
Ok(())
}
/// Add an executor without replacing CPU jobs or their ownership. Applications share
/// this host across document sessions; descriptor IDs must be unique in the registry.
pub fn register_render_job_host(&mut self, host: solidcraft_render::job::ImageHost) -> crate::Result<()> {
let descriptor = host.descriptor().map_err(job_error)?;
if self.render_jobs.additional.iter().any(|(old, _)| old.same_host(&host)) {
return Ok(());
}
if descriptor.profiles.iter().any(|p| p.operation != solidcraft_render::RenderOperation::Image) {
return Err(crate::EngineError::Other("image host must only register image profiles".into()));
}
let mut registry = self.render_backend();
registry.executors.push(descriptor.clone());
solidcraft_render::resolve_request(
&[registry],
&solidcraft_render::RenderRequest {
backend: None,
mode: solidcraft_render::RenderMode::Preview,
operation: solidcraft_render::RenderOperation::Image,
width: 1,
height: 1,
format: Some(solidcraft_render::RenderFormat::Png),
features: Vec::new(),
policy: Default::default(),
},
)
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
self.render_jobs.additional.push((host, descriptor));
Ok(())
}
pub fn render_job_host(&self) -> Option<&CpuHost> {
self.render_jobs.host.as_ref()
}
+2
View File
@@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
robust = "1.2"
solidcraft-geom = { workspace = true }
solidcraft-render = { workspace = true }
wgpu = { workspace = true }
@@ -22,6 +23,7 @@ pollster = { workspace = true }
wgpu = { workspace = true, features = ["metal", "vulkan", "dx12", "gles"] }
[dev-dependencies]
image = { workspace = true }
solidcraft-kernel = { workspace = true }
# Headless pictures of real designs (capture tests).
solidcraft-engine = { workspace = true }
+11 -4
View File
@@ -20,6 +20,8 @@ use solidcraft_geom::tess::{EvalCurve, EvalSurface, TessPlan};
use wgpu::util::DeviceExt;
pub mod capture;
#[cfg(not(target_arch = "wasm32"))]
pub mod path;
mod pbr;
#[cfg(not(target_arch = "wasm32"))]
pub mod select;
@@ -65,14 +67,19 @@ fn popped(f: impl std::future::Future<Output = Option<wgpu::Error>>) -> Option<w
fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let policy = select::Policy::from_env().map_err(GpuError::Policy)?;
let (adapter, _) = select::pick(policy).ok_or(GpuError::NoAdapter(policy.name()))?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
let (device, queue) = open_device(&adapter)?;
Ok((adapter, device, queue))
}
#[cfg(not(target_arch = "wasm32"))]
fn open_device(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue)> {
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("solidcraft headless"),
required_features: depth_features(&adapter),
required_features: depth_features(adapter),
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
..Default::default()
}))
.map_err(|e| GpuError::Device(e.to_string()))?;
Ok((adapter, device, queue))
.map_err(|e| GpuError::Device(e.to_string()))
}
/// Most vertices evaluated in one dispatch (bounds the buffers; larger plans go in batches).
+218
View File
@@ -0,0 +1,218 @@
//! Optional output filtering; never writes the Monte Carlo accumulation buffer.
use super::{Context, Result, SHADER, TIMEOUT, Task, checked, placeholder_bind, scopes, warm_up};
use wgpu::util::DeviceExt;
pub(super) const BYTES_PER_PIXEL: u64 = 80; // three guide vec4s and two colour vec4s
const FILTER: &str = include_str!("denoise.wgsl");
const GUIDES: &str = include_str!("denoise_guides.wgsl");
#[cfg(test)]
#[path = "denoise_tests.rs"]
mod tests;
// Shared by every denoised job on a device: the guide pipeline compiles the whole
// path shader, so it is built and warmed once rather than per job.
pub(super) struct Pipelines {
guide: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
filter_layout: wgpu::BindGroupLayout,
}
impl Pipelines {
fn new(context: &Context) -> Result<Self> {
let device = &context.device;
let error_scopes = scopes(device);
let guide_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("denoise guides"),
source: wgpu::ShaderSource::Wgsl(format!("{SHADER}\n{GUIDES}").into()),
});
let guide_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("denoise guides"),
bind_group_layouts: &[Some(&context.layout)],
immediate_size: 0,
});
let guide = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("denoise guides"),
layout: Some(&guide_layout),
module: &guide_shader,
entry_point: Some("denoise_guides"),
compilation_options: Default::default(),
cache: None,
});
let entries: Vec<_> = (0..4)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: if binding == 0 { wgpu::BufferBindingType::Uniform } else { wgpu::BufferBindingType::Storage { read_only: binding != 3 } },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect();
let filter_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("denoise filter"), entries: &entries });
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("denoise filter"),
bind_group_layouts: &[Some(&filter_layout)],
immediate_size: 0,
});
let shader = device
.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("denoise filter"), source: wgpu::ShaderSource::Wgsl(FILTER.into()) });
let filter = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("denoise filter"),
layout: Some(&layout),
module: &shader,
entry_point: Some("atrous"),
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 })
}
fn shared(context: &Context) -> Result<&Self> {
if let Some(pipelines) = context.denoise.get() {
return Ok(pipelines);
}
let built = Self::new(context)?;
Ok(context.denoise.get_or_init(|| built))
}
}
pub(super) struct Resources {
guide_pipeline: wgpu::ComputePipeline,
guide_bind: wgpu::BindGroup,
filter_pipeline: wgpu::ComputePipeline,
filter_uniform: wgpu::Buffer,
filter_binds: Vec<wgpu::BindGroup>,
output: wgpu::Buffer,
// Retained for diagnostic tests; bind groups also retain the guide allocation.
_guides: wgpu::Buffer,
}
impl Resources {
pub(super) fn new(
context: &Context,
path_uniform: &wgpu::Buffer,
scene: [&wgpu::Buffer; 5],
accumulation: &wgpu::Buffer,
size: u64,
) -> Result<Self> {
let pipelines = Pipelines::shared(context)?;
let device = &context.device;
let error_scopes = scopes(device);
let buffer = |label, bytes| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
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 [triangles, nodes, environment, textures, failures] = scene;
let entries: Vec<_> = [path_uniform, triangles, nodes, &guides, environment, textures, failures]
.into_iter()
.enumerate()
.map(|(binding, value)| wgpu::BindGroupEntry { binding: binding as u32, resource: value.as_entire_binding() })
.collect();
let guide_bind =
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],
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let filter_binds = [(accumulation, &output), (&output, &temporary), (&temporary, &output)]
.into_iter()
.map(|(source, destination)| {
let entries: Vec<_> = [&filter_uniform, &guides, source, destination]
.into_iter()
.enumerate()
.map(|(binding, value)| wgpu::BindGroupEntry { binding: binding as u32, resource: value.as_entire_binding() })
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("denoise filter"),
layout: &pipelines.filter_layout,
entries: &entries,
})
})
.collect();
checked(error_scopes)?;
Ok(Self {
guide_pipeline: pipelines.guide.clone(),
guide_bind,
filter_pipeline: pipelines.filter.clone(),
filter_uniform,
filter_binds,
output,
_guides: guides,
})
}
pub(super) fn output(&self) -> &wgpu::Buffer {
&self.output
}
pub(super) fn run(&self, task: &Task) -> Result<()> {
let pixels = task.request.width * task.request.height;
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 parameters = task.parameters(count);
if let Some(bytes) = parameters.get_mut(12..16) {
bytes.copy_from_slice(&offset.to_le_bytes());
}
task.context.queue.write_buffer(&task.uniform, 0, &parameters);
let started = std::time::Instant::now();
dispatch(&task.context, &self.guide_pipeline, &self.guide_bind, count)?;
budget.observe(count, started.elapsed());
offset += count;
}
self.run_filter(task)
}
fn run_filter(&self, task: &Task) -> Result<()> {
let pixels = task.request.width * task.request.height;
for (pass, bind) in self.filter_binds.iter().enumerate() {
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, 1 << pass, offset, count, task.sample, 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.filter_pipeline, bind, count)?;
budget.observe(count, started.elapsed());
offset += count;
}
}
task.cancel.check()
}
}
fn dispatch(context: &Context, pipeline: &wgpu::ComputePipeline, bind: &wgpu::BindGroup, count: u32) -> Result<()> {
context.ensure_available()?;
let error_scopes = scopes(&context.device);
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("denoise tile") });
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("denoise tile"), timestamp_writes: None });
pass.set_pipeline(pipeline);
pass.set_bind_group(0, bind, &[]);
pass.dispatch_workgroups(count.div_ceil(64), 1, 1);
}
let submission = context.queue.submit([encoder.finish()]);
checked(error_scopes)?;
context.wait(submission, TIMEOUT)?;
Ok(())
}
+48
View File
@@ -0,0 +1,48 @@
// Original three-pass 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> };
@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 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); }
@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 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;
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 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);
if weight>0.0 {
total+=weight;
// Online convex averaging avoids overflow of radiance times kernel.
mean+=(v.xyz-mean)*(weight/total);
}
}}
// The centre always contributes; retain original coverage without filtering.
filtered[pixel]=vec4<f32>(mean,c.w);
}
+35
View File
@@ -0,0 +1,35 @@
// Original deterministic first-hit guides. Uses the production intersection and
// material/normal texture evaluation, without advancing the path random stream.
@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;
accumulation[base]=vec4<f32>(0.0);
accumulation[base+1u]=vec4<f32>(0.0);
accumulation[base+2u]=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);
t=texture_surface(t,position);
// 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 geometric=unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
if dot(geometric,-ray.direction)<0.0 { geometric=-geometric; }
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=texture_normal(t,position,n,geometric);
if dot(mapped,-ray.direction)>0.0 { n=mapped; }
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));
}
+331
View File
@@ -0,0 +1,331 @@
use super::super::{Executor, JobOptions, Task, open_headless};
use super::*;
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_render::{
Camera, ProductMesh, ProductScene, StandardView,
job::{CancelToken, ImageRequest, ImageWork, TaskPoll},
surface::OpaqueSurface,
};
use std::sync::Arc;
type Guide = [[f64; 4]; 3];
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a.into_iter().zip(b).map(|(x, y)| x * y).sum()
}
fn diff(a: [f64; 4], b: [f64; 4]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn xyz(a: [f64; 4]) -> [f64; 3] {
[a[0], a[1], a[2]]
}
// 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]> {
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 {
return center;
}
let mut result = [0.0; 4];
let mut total = 0.0;
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;
let y = (i / width) as isize + (ky as isize - 2) * step as isize;
if x < 0 || y < 0 || x >= width as isize || y >= height as isize {
continue;
}
let j = y as usize * width + x as usize;
let [b, m, q] = guides[j];
let value = input[j];
if a[3] != b[3] || (value[3] - center[3]).abs() > 0.01 {
continue;
}
let albedo = diff(a, b);
let position = diff(q, p);
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);
for k in 0..3 {
result[k] += value[k] * weight;
}
total += weight;
}
}
for v in result.iter_mut().take(3) {
*v /= total;
}
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]]
}
fn fixture(size: usize) -> (Vec<[f64; 4]>, Vec<Guide>) {
let mut pixels = Vec::new();
let mut guides = Vec::new();
for y in 0..size {
for x in 0..size {
// Deterministic white noise in [0.4, 0.6]. A pixel-frequency checker would
// be invisible to the step-2 and step-4 passes, which only reach same-parity pixels.
let hash = ((x * 7919 + y * 104_729) as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) >> 40;
let level = 0.4 + 0.2 * hash as f64 / f64::from(1u32 << 24);
pixels.push([level, level, level, 1.0]);
guides.push(guide(x, y));
}
}
(pixels, guides)
}
#[test]
fn f64_atrous_reduces_flat_noise_preserves_constant_and_coverage() {
let (input, guides) = fixture(17);
let mut result = input.clone();
for step in [1, 2, 4] {
result = reference(17, &result, &guides, step);
}
let variance = |image: &[[f64; 4]]| {
let mean = image.iter().map(|p| p[0]).sum::<f64>() / image.len() as f64;
image.iter().map(|p| (p[0] - mean).powi(2)).sum::<f64>() / image.len() as f64
};
println!("noise variance {} -> {}", variance(&input), variance(&result));
assert!(variance(&result) < variance(&input) / 4.0, "noise variance {} -> {}", variance(&input), variance(&result));
assert_eq!(reference(17, &vec![[0.7, 0.3, 0.1, 0.5]; 289], &guides, 1), vec![[0.7, 0.3, 0.1, 0.5]; 289]);
let constant = reference(17, &vec![[0.75, 0.75, 0.75, 1.0]; 289], &guides, 4);
assert!(constant.iter().all(|p| p[..3].iter().all(|x| (*x - 0.75).abs() < 1e-14)));
}
#[test]
fn f64_atrous_preserves_body_normal_depth_and_albedo_edges() {
for edge in 0..5 {
let mut pixels = Vec::new();
let mut guides = Vec::new();
for y in 0..17 {
for x in 0..17 {
let mut g = guide(x, y);
let c = if x < 8 { 0.0 } else { 1.0 };
if x >= 8 {
match edge {
0 => g[0][3] = 2.0,
1 => g[1] = [1.0, 0.0, 0.0, 4.0],
2 => g[2][2] = 10.0,
4 => g[1][3] = 1004.0,
_ => g[0] = [1.0, 1.0, 1.0, 1.0],
}
}
if edge == 3 && x < 8 {
g[0] = [0.0, 0.0, 0.0, 1.0];
}
pixels.push([c, c, c, 1.0]);
guides.push(g);
}
}
let mut filtered = pixels.clone();
for step in [1, 2, 4] {
filtered = reference(17, &filtered, &guides, step);
}
assert!(filtered.iter().zip(&pixels).all(|(a, b)| (a[0] - b[0]).abs() < 1e-12), "edge {edge}");
}
}
fn context() -> Option<Arc<Context>> {
let (_, device, queue) = match open_headless() {
Ok(v) => v,
Err(e) => {
// The path tracer runs on Vulkan only: where the backends in use leave Vulkan out
// (macOS, or `WGPU_BACKEND=dx12`), it is unavailable by design, not missing.
let vulkan = crate::select::native_backends().contains(wgpu::Backends::VULKAN);
assert!(!vulkan || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required GPU unavailable: {e}");
return None;
}
};
Some(Executor::new(device, queue).unwrap().context)
}
fn request(size: u32) -> ImageRequest {
let mut camera = Camera { distance: 4.0, ..Default::default() };
camera.set_view(StandardView::Top);
ImageRequest { camera, width: size, height: size, background: [0; 3], transparent_background: false, environment: None, textures: None }
}
fn plane() -> ProductScene {
let mesh = Mesh {
positions: vec![Vec3::new(-1.0, -1.0, 0.0), Vec3::new(1.0, -1.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(-1.0, 1.0, 0.0)],
normals: vec![Vec3::Z; 4],
triangles: vec![[0, 1, 2], [0, 2, 3]],
tri_face: vec![0, 0],
..Default::default()
};
ProductScene {
bounds: mesh.bounds(),
meshes: vec![ProductMesh {
name: "original denoise plane".into(),
mesh: Arc::new(mesh),
color: [255; 3],
opacity: 1.0,
surface: Some(OpaqueSurface::new(Vec3::new(0.5, 0.5, 0.5), 0.0, 1.0, 1.5).unwrap()),
transmission: None,
faces: Vec::new(),
}],
}
}
fn read_guides(context: &Context, buffer: &wgpu::Buffer) -> Vec<[f32; 4]> {
let staging = context.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: buffer.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = context.device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, 0, &staging, 0, buffer.size());
let submission = context.queue.submit([encoder.finish()]);
let (tx, rx) = std::sync::mpsc::channel();
staging.slice(..).map_async(wgpu::MapMode::Read, move |v| {
tx.send(v).unwrap();
});
context.device.poll(wgpu::PollType::Wait { submission_index: Some(submission), timeout: Some(TIMEOUT) }).unwrap();
rx.recv().unwrap().unwrap();
let mapped = staging.slice(..).get_mapped_range().unwrap();
let values = mapped.as_chunks::<16>().0.iter().map(|v| [0, 4, 8, 12].map(|i| f32::from_le_bytes(v[i..i + 4].try_into().unwrap()))).collect();
drop(mapped);
staging.unmap();
values
}
#[test]
fn denoise_admission_counts_guides_and_ping_pong_only_when_enabled() {
let scene = plane();
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);
let large = ImageRequest { width: 2000, height: 2000, ..request.clone() };
assert!(super::super::admission(&scene, &large, JobOptions::default()).is_ok());
assert_eq!(
super::super::admission(&scene, &large, JobOptions { denoise: true, ..Default::default() }).unwrap_err().code,
solidcraft_render::job::ErrorCode::LimitExceeded
);
assert!(!serde_json::from_str::<JobOptions>("{}").unwrap().denoise);
assert!(serde_json::from_str::<JobOptions>(r#"{"denoise":"true"}"#).is_err());
}
#[test]
fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
let Some(context) = context() else {
return;
};
let (mut input, mut guides) = fixture(13);
for y in 0..13 {
for x in 7..13 {
guides[y * 13 + x][0][3] = 2.0;
input[y * 13 + x][0] += 0.8;
}
}
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 device = &context.device;
let make = |label, bytes: &[u8]| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
})
};
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 filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64).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).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);
}
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:?}");
}
}
}
#[test]
fn gpu_denoise_default_false_is_identical_and_true_preserves_raw() {
let Some(context) = context() else {
return;
};
let mut outputs = Vec::new();
let mut pngs = Vec::new();
for options in [
JobOptions { samples: 4, ..Default::default() },
JobOptions { samples: 4, denoise: false, ..Default::default() },
JobOptions { samples: 4, denoise: true, ..Default::default() },
] {
let mut task = Task::new(context.clone(), plane(), request(8), options, CancelToken::default()).unwrap();
assert_eq!(task.denoiser.is_some(), options.denoise);
while task.sample < 4 {
task.complete_sample().unwrap();
}
let raw = task.read_linear().unwrap();
if let Some(filter) = &task.denoiser {
filter.run(&task).unwrap();
assert_eq!(raw, task.read_linear().unwrap());
let filtered = task.read_linear_buffer(filter.output()).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[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);
assert!((guides[center + 2][1] + 0.2).abs() < 1e-6);
assert!((guides[center + 2][3] - 0.4).abs() < 1e-6);
}
outputs.push(raw);
for _ in 0..100 {
if let TaskPoll::Complete(png) = task.step(4096).unwrap() {
pngs.push(png);
break;
}
}
}
assert_eq!(outputs[0], outputs[1]);
assert_eq!(outputs[0], outputs[2]);
assert_eq!(pngs.len(), 3);
assert_eq!(pngs[0], pngs[1]);
}
#[test]
fn gpu_denoise_preflight_reports_limits_without_changing_document() {
let Some(context) = context() else {
return;
};
let mut session = solidcraft_engine::Session::default();
session.set_render_job_host(Some(solidcraft_render::job::CpuHost::cooperative())).unwrap();
session.register_render_job_host(solidcraft_render::job::ImageHost::with_executor(Arc::new(Executor { context })).unwrap()).unwrap();
session.execute("solid.box", &serde_json::json!({"length":2,"width":2,"height":1})).unwrap();
let before = session.doc.to_json();
let mut params = serde_json::json!({"progressive":true,"denoise":true,"settings":{"width":8,"height":8}});
let reply = session.execute("render.preflight", &params).unwrap();
assert_eq!(reply["options"]["denoise"], true);
assert_eq!(reply["denoising"]["passes"], 3);
assert_eq!(reply["denoising"]["raw_linear_accumulator_preserved"], true);
assert_eq!(before, session.doc.to_json());
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());
}
+70
View File
@@ -0,0 +1,70 @@
//! Conservative work quanta selected from actual submission completion latency.
//! This is a bounded controller, not a guarantee about a driver's scheduling deadline.
use std::time::Duration;
pub(super) const MIN_PATHS: u32 = 64;
pub(super) const MAX_PATHS: u32 = 16_384;
const TARGET: Duration = Duration::from_millis(20);
#[derive(Clone, Copy, Debug)]
pub(super) struct Budget {
paths: u32,
}
impl Default for Budget {
fn default() -> Self {
Self { paths: MIN_PATHS }
}
}
impl Budget {
pub(super) fn count(&self, remaining: u32) -> u32 {
remaining.min(self.paths)
}
pub(super) fn observe(&mut self, completed: u32, elapsed: Duration) {
if completed == 0 {
return;
}
let ideal = u128::from(completed).saturating_mul(TARGET.as_nanos()) / elapsed.as_nanos().max(1);
let bounded = ideal.clamp(u128::from(MIN_PATHS), u128::from(MAX_PATHS));
let requested = u32::try_from(bounded).unwrap_or(MIN_PATHS);
// Never jump from the first tiny/fast dispatch to a large unmeasured one.
self.paths = requested.min(self.paths.saturating_mul(2)).clamp(MIN_PATHS, MAX_PATHS);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cold_dispatch_starts_small_and_growth_is_bounded() {
let mut budget = Budget::default();
assert_eq!(budget.count(u32::MAX), 64);
for _ in 0..100 {
let before = budget.count(u32::MAX);
budget.observe(before, Duration::from_nanos(1));
let after = budget.count(u32::MAX);
assert!(after <= before * 2 && after <= MAX_PATHS);
}
assert_eq!(budget.count(u32::MAX), MAX_PATHS);
budget.observe(MAX_PATHS, Duration::from_secs(10));
assert_eq!(budget.count(u32::MAX), MIN_PATHS);
assert_eq!(budget.count(17), 17);
assert_eq!(budget.count(0), 0);
}
#[test]
fn observed_cost_controls_the_quantum_without_overflow_or_zero_work() {
let mut budget = Budget::default();
for _ in 0..10 {
budget.observe(budget.paths, Duration::from_millis(1));
}
let before = budget.paths;
budget.observe(before, Duration::from_millis(80));
assert_eq!(budget.paths, before / 4);
let unchanged = budget.paths;
budget.observe(0, Duration::ZERO);
assert_eq!(budget.paths, unchanged);
budget.observe(u32::MAX, Duration::MAX);
assert_eq!(budget.paths, MIN_PATHS);
budget.observe(u32::MAX, Duration::ZERO);
assert_eq!(budget.paths, MIN_PATHS * 2);
}
}
+743
View File
@@ -0,0 +1,743 @@
//! Native progressive product rendering. Software BVH traversal needs no ray-query
//! features. Small measured quanta adapt to the device, then check cancellation.
use solidcraft_geom::Vec3;
use solidcraft_render::{
Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFeature, RenderFormat, RenderLimits, RenderMode, RenderOperation,
RenderProfile, ScratchLayout,
job::{
Admission, CancelToken, ErrorCode, ImageExecutor, ImageHost, ImageRequest, ImageWork, JobError, JobOptions, Phase, PngTask, Progress, Result,
TaskPoll, ToneMap, admit_geometry,
},
};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use wgpu::util::DeviceExt;
mod denoise;
mod dispatch;
mod scene;
#[cfg(test)]
mod tests;
mod textures;
const SHADER: &str = concat!(include_str!("path.wgsl"), "\n", include_str!("shadows.wgsl"), "\n", include_str!("textures.wgsl"));
const MAX_TRIANGLES: u64 = 1_048_576;
const PARAMETER_BYTES: usize = 448;
// Eight u32 words: count low, pixel, bounce, sample, code, count high, first-writer flag, spare.
const FAILURE_BYTES: u64 = 32;
// A path that cannot classify its glass medium ends there. Isolated paths at the
// limits of f32 geometry are tolerated and logged; more than this fraction of all
// paths (any systematic error such as a camera inside glass) fails the job.
const FAILURE_FRACTION: f64 = 1e-6;
const TIMEOUT: Duration = Duration::from_secs(30);
// Software Vulkan drivers can compile a pipeline at its first dispatch, which on a cold
// shader cache exceeds the per-dispatch hang timeout. Setup dispatches once with no work
// and allows this longer wait, so jobs keep the short one.
const SETUP_TIMEOUT: Duration = Duration::from_secs(600);
fn gpu(e: impl std::fmt::Display) -> JobError {
JobError::new(ErrorCode::HostUnavailable, e.to_string())
}
fn scopes(device: &wgpu::Device) -> [wgpu::ErrorScopeGuard; 3] {
[wgpu::ErrorFilter::Validation, wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal].map(|f| device.push_error_scope(f))
}
fn checked(scopes: [wgpu::ErrorScopeGuard; 3]) -> Result<()> {
let mut error = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = pollster::block_on(scope.pop()) {
error = Some(e);
}
}
error.map_or(Ok(()), |e| Err(gpu(e)))
}
/// Drain every scope, in reverse order, even when resource creation returns an error.
/// The ordinary cancellation/error path must not drop an array of guards forwards.
fn checked_creation<T>(device: &wgpu::Device, create: impl FnOnce() -> Result<T>) -> Result<T> {
let error_scopes = scopes(device);
let result = create();
checked(error_scopes)?;
result
}
/// Open and validate a native device before advertising the executor. Failure leaves the
/// caller's CPU host usable. Respects SOLIDCRAFT_GPU, including the software adapter policy.
pub fn host() -> Result<ImageHost> {
let (adapter, device, queue) = open_headless()?;
log::info!("gpu-path adapter: {} ({:?})", adapter.get_info().name, adapter.get_info().device_type);
ImageHost::with_executor(Arc::new(Executor::new(device, queue)?))
}
/// Open an independent job device on the viewport's selected adapter. In particular,
/// software fallback and safe mode must not rediscover a failing hardware driver.
/// An explicit incompatible SOLIDCRAFT_GPU policy leaves this executor unavailable.
/// The path tracer runs on Vulkan only: a viewport on another backend is reopened on the same
/// GPU through Vulkan, and without one the executor is unavailable and images render on the CPU.
pub fn host_on_adapter(adapter: &wgpu::Adapter) -> Result<ImageHost> {
let policy = crate::select::Policy::from_env().map_err(gpu)?;
let info = adapter.get_info();
if !policy.admits(crate::select::tier_of(info.device_type)) {
return Err(gpu(format!("viewport adapter does not satisfy SOLIDCRAFT_GPU={}", policy.name())));
}
if info.backend != wgpu::Backend::Vulkan {
let (vulkan, _) = crate::select::pick_vulkan(policy, |i| i.vendor == info.vendor && i.device == info.device)
.ok_or_else(|| gpu(format!("{VULKAN_ONLY}; the viewport uses {:?}", info.backend)))?;
let (device, queue) = open_device(&vulkan)?;
log::info!("gpu-path viewport GPU: {} through Vulkan", info.name);
return ImageHost::with_executor(Arc::new(Executor::new(device, queue)?));
}
let (device, queue) = open_device(adapter)?;
log::info!("gpu-path viewport adapter: {} ({:?})", info.name, info.device_type);
ImageHost::with_executor(Arc::new(Executor::new(device, queue)?))
}
/// Why the executor is unavailable without Vulkan (#3217).
const VULKAN_ONLY: &str = "the GPU path tracer needs a Vulkan driver (images render on the CPU without one)";
// Path resources need six storage buffers (geometry, BVH, accumulation, HDRI,
// textures, medium-failure counts). Request that limit only on this independent path device: raster and
// tessellation keep their existing four-buffer downlevel requirement.
const PATH_STORAGE_BUFFERS: u32 = 6;
fn device_limits(supported: wgpu::Limits) -> Result<wgpu::Limits> {
if supported.max_storage_buffers_per_shader_stage < PATH_STORAGE_BUFFERS {
return Err(gpu("gpu-path requires six storage buffers per shader stage"));
}
let mut limits = wgpu::Limits::downlevel_defaults().using_resolution(supported);
limits.max_storage_buffers_per_shader_stage = PATH_STORAGE_BUFFERS;
Ok(limits)
}
fn open_device(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue)> {
let required_limits = device_limits(adapter.limits())?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("solidcraft progressive path"),
required_features: crate::depth_features(adapter),
required_limits,
..Default::default()
}))
.map_err(gpu)
}
fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let policy = crate::select::Policy::from_env().map_err(gpu)?;
let (adapter, _) = crate::select::pick_vulkan(policy, |_| true)
.ok_or_else(|| gpu(format!("{VULKAN_ONLY}; no Vulkan adapter satisfies SOLIDCRAFT_GPU={}", policy.name())))?;
let (device, queue) = open_device(&adapter)?;
Ok((adapter, device, queue))
}
struct Context {
healthy: Arc<AtomicBool>,
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::ComputePipeline,
// Built and warmed by the first denoised job, then shared by later ones.
denoise: std::sync::OnceLock<denoise::Pipelines>,
layout: wgpu::BindGroupLayout,
}
impl Context {
fn ensure_available(&self) -> Result<()> {
if self.healthy.load(Ordering::Acquire) {
Ok(())
} else {
Err(JobError::new(
ErrorCode::HostUnavailable,
"GPU path device was lost or failed to complete work; restart the renderer before retrying",
))
}
}
fn wait(&self, submission: wgpu::SubmissionIndex, timeout: Duration) -> Result<()> {
wait_device(&self.device, &self.healthy, submission, timeout)
}
}
fn wait_device(device: &wgpu::Device, healthy: &AtomicBool, submission: wgpu::SubmissionIndex, timeout: Duration) -> Result<()> {
if !healthy.load(Ordering::Acquire) {
return Err(JobError::new(ErrorCode::HostUnavailable, "GPU path device is unavailable"));
}
if let Err(error) = device.poll(wgpu::PollType::Wait { submission_index: Some(submission), timeout: Some(timeout) }) {
healthy.store(false, Ordering::Release);
return Err(JobError::new(ErrorCode::HostUnavailable, format!("GPU path completion failed; executor retired: {error}")));
}
if !healthy.load(Ordering::Acquire) {
return Err(JobError::new(ErrorCode::HostUnavailable, "GPU path device was lost while completing work"));
}
Ok(())
}
struct Executor {
context: Arc<Context>,
}
impl Executor {
fn new(device: wgpu::Device, queue: wgpu::Queue) -> Result<Self> {
device_limits(device.limits())?;
let healthy = Arc::new(AtomicBool::new(true));
let uncaptured = healthy.clone();
device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| {
uncaptured.store(false, Ordering::Release);
log::error!("gpu-path executor retired after uncaptured error: {e}");
}));
let lost = healthy.clone();
device.set_device_lost_callback(move |reason, message| {
lost.store(false, Ordering::Release);
log::warn!("gpu-path device lost ({reason:?}); executor unavailable: {message}");
});
let scopes = scopes(&device);
let shader =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("gpu-path"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
let entries: Vec<_> = (0..7)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: if binding == 0 {
wgpu::BufferBindingType::Uniform
} else {
wgpu::BufferBindingType::Storage { read_only: binding != 3 && binding != 6 }
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("gpu-path"), entries: &entries });
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("gpu-path"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("gpu-path"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
checked(scopes)?;
warm_up(&device, &queue, &pipeline, &placeholder_bind(&device, &layout, 7)?, &healthy)?;
Ok(Self { context: Arc::new(Context { healthy, device, queue, pipeline, layout, denoise: std::sync::OnceLock::new() }) })
}
}
// Small zeroed bindings: binding 0 is a uniform whose zero work count makes every
// invocation return at once; the others are storage buffers.
fn placeholder_bind(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, count: u32) -> Result<wgpu::BindGroup> {
let scopes = scopes(device);
let buffers: Vec<_> = (0..count)
.map(|binding| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("gpu-path warm-up"),
size: if binding == 0 { PARAMETER_BYTES as u64 } else { 256 },
usage: if binding == 0 { wgpu::BufferUsages::UNIFORM } else { wgpu::BufferUsages::STORAGE },
mapped_at_creation: false,
})
})
.collect();
let entries: Vec<_> = buffers
.iter()
.enumerate()
.map(|(binding, buffer)| wgpu::BindGroupEntry { binding: binding as u32, resource: buffer.as_entire_binding() })
.collect();
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("gpu-path warm-up"), layout, entries: &entries });
checked(scopes)?;
Ok(bind)
}
fn warm_up(device: &wgpu::Device, queue: &wgpu::Queue, pipeline: &wgpu::ComputePipeline, bind: &wgpu::BindGroup, healthy: &AtomicBool) -> Result<()> {
let scopes = scopes(device);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu-path warm-up") });
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("gpu-path warm-up"), timestamp_writes: None });
pass.set_pipeline(pipeline);
pass.set_bind_group(0, bind, &[]);
pass.dispatch_workgroups(1, 1, 1);
}
let submission = queue.submit([encoder.finish()]);
checked(scopes)?;
wait_device(device, healthy, submission, SETUP_TIMEOUT)?;
Ok(())
}
fn medium_failures_tolerated(failed: u64, paths: u64) -> bool {
failed as f64 <= paths as f64 * FAILURE_FRACTION
}
fn descriptor() -> Result<ExecutorDescriptor> {
Ok(ExecutorDescriptor {
id: ExecutorId::new("gpu-path").map_err(gpu)?,
availability: Availability::Available,
profiles: vec![RenderProfile {
mode: RenderMode::Preview,
operation: RenderOperation::Image,
formats: vec![RenderFormat::Png],
features: vec![RenderFeature::Pbr, RenderFeature::Progressive, RenderFeature::Refraction, RenderFeature::Hdri],
}],
limits: RenderLimits::default(),
scratch: ScratchLayout { sample_scale: 1, sample_bytes_per_pixel: 16, output_bytes_per_pixel: 16, encoding_bytes_per_pixel: 16 },
})
}
fn admission(scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
options.validate()?;
if let Some(environment) = &request.environment {
environment.validate()?;
}
let camera = request.camera;
if options.lens.is_some_and(|lens| lens.aperture_radius_mm > 0.0)
&& (camera.distance < 1e-6 || !(1e-6..=1e6).contains(&(camera.half_height() / camera.distance)) || camera.fov == 0.0)
{
return Err(JobError::new(
ErrorCode::InvalidRequest,
"thin-lens depth of field requires a bounded perspective camera (distance >= 0.000001 mm and tan(fov/2) in 0.000001..1000000)",
));
}
if !camera.fov.is_finite()
|| !(0.0..std::f64::consts::PI).contains(&camera.fov)
|| !camera.half_height().is_finite()
|| camera.half_height() > 1e12
|| camera.distance > 1e9
{
return Err(JobError::new(ErrorCode::InvalidRequest, "gpu-path requires a bounded finite camera"));
}
let mut admission = admit_geometry(scene, request, MAX_TRIANGLES)?;
if scene
.meshes
.iter()
.any(|m| (m.opacity != 1.0 && m.transmission.is_none()) || m.faces.iter().any(|f| f.opacity != 1.0 && f.transmission.is_none()))
{
return Err(JobError::new(ErrorCode::InvalidScene, "gpu-path requires explicit transmission for nonopaque preview appearances"));
}
// Peak preparation: f64 triangles + nodes + serialized upload. After preparation:
// serialized + device geometry + accumulation/readback/RGBA/PNG. These phases don't overlap.
let pixels = u64::from(request.width) * u64::from(request.height);
let denoise_bytes = pixels * if options.denoise { denoise::BYTES_PER_PIXEL } else { 0 };
admission.pixel_scratch_bytes = pixels * 64 + denoise_bytes;
let vertex_bytes = scene.meshes.iter().map(|m| m.mesh.positions.len() as u64 * 32).sum::<u64>();
let triangle_preparation =
(admission.triangles * 400_u64.max(scene::planar_preparation_bytes_per_triangle() as u64)).max(scene::medium_preparation_bytes(scene));
admission.preparation_bytes = (triangle_preparation + vertex_bytes * 2 + admission.face_appearances * 8)
.max(admission.triangles * 232 + vertex_bytes * 2 + pixels * 32 + denoise_bytes)
.max(admission.triangles * 116 + vertex_bytes + admission.pixel_scratch_bytes)
+ 1_048_576;
admission.preparation_bytes += request.environment.as_ref().map_or(0, |source| source.admitted_bytes());
if let Some(textures) = &request.textures {
textures.validate_scene(scene)?;
admission.preparation_bytes += textures.working_bytes();
}
admission.estimated_total_bytes = admission.geometry_bytes + admission.preparation_bytes;
if admission.estimated_total_bytes > 512 * 1024 * 1024 {
return Err(JobError::new(ErrorCode::LimitExceeded, "gpu-path job exceeds its 512 MiB admission estimate"));
}
Ok(admission)
}
impl ImageExecutor for Executor {
fn descriptor(&self) -> Result<ExecutorDescriptor> {
let mut value = descriptor()?;
if !self.context.healthy.load(Ordering::Acquire) {
value.availability = Availability::Unavailable { reason: "GPU path device was lost or failed to complete work; restart renderer".into() };
}
Ok(value)
}
fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
self.context.ensure_available()?;
let a = admission(scene, request, options)?;
let limit = self.context.device.limits().max_storage_buffer_binding_size;
let geometry_bytes = 16 + a.triangles.max(1) * 84 + scene.meshes.iter().map(|m| m.mesh.positions.len() as u64 * 32).sum::<u64>();
let texture_bytes = request
.textures
.as_ref()
.map_or(16, |t| t.summary().linear_pixel_bytes + 4 * (5 + scene.meshes.len() as u64 + t.bindings().len() as u64 * 16));
let environment_bytes =
request.environment.as_ref().map_or(48, |source| 16 + u64::from(source.info().width) * u64::from(source.info().height) * 32);
if texture_bytes > limit
|| environment_bytes > limit
|| geometry_bytes > limit
|| u64::from(request.width) * u64::from(request.height) * 16 > limit
|| (options.denoise && u64::from(request.width) * u64::from(request.height) * 48 > limit)
{
return Err(JobError::new(ErrorCode::LimitExceeded, "gpu-path storage buffer exceeds this device's limit"));
}
Ok(a)
}
fn begin(&self, scene: ProductScene, request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
self.admit(&scene, &request, options)?;
Ok(Box::new(Task::new(self.context.clone(), scene, request, options, cancel)?))
}
}
/// Build all fallible CPU environment data before opening GPU error scopes.
fn prepare_environment(request: &ImageRequest, cancel: &CancelToken) -> Result<Vec<u8>> {
cancel.check()?;
let mut environment_bytes = Vec::new();
if let Some(source) = &request.environment {
let prepared = source.prepare(cancel)?;
environment_bytes = scene::reserved(16 + prepared.texels().len() * 32)?;
let [width, height] = prepared.dimensions();
for x in [width, height, width * height, 0] {
environment_bytes.extend_from_slice(&x.to_le_bytes());
}
for (index, texel) in prepared.texels().iter().enumerate() {
if index % 1024 == 0 {
cancel.check()?;
}
for x in texel.rgb.into_iter().chain([texel.pdf, texel.threshold]) {
environment_bytes.extend_from_slice(&x.to_le_bytes());
}
environment_bytes.extend_from_slice(&texel.alias.to_le_bytes());
environment_bytes.extend_from_slice(&[0; 8]);
}
} else {
environment_bytes.resize(48, 0);
}
Ok(environment_bytes)
}
struct Task {
dispatch_budget: dispatch::Budget,
context: Arc<Context>,
request: ImageRequest,
options: JobOptions,
cancel: CancelToken,
uniform: wgpu::Buffer,
accumulation: wgpu::Buffer,
failures: wgpu::Buffer,
readback: wgpu::Buffer,
bind: wgpu::BindGroup,
denoiser: Option<denoise::Resources>,
sample: u32,
offset: u32,
epsilon: f32,
png: Option<PngTask>,
started: Instant,
// Uniform environment is used only by the numerical furnace test, never advertised as HDRI.
environment: f32,
environment_bottom_only: bool,
studio: f32,
#[cfg(test)]
metrics: (f64, u64, f64),
}
impl Task {
fn new(context: Arc<Context>, scene: ProductScene, mut request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Self> {
cancel.check()?;
let start = Instant::now();
if let Some(resources) = &request.textures {
resources.validate_scene(&scene)?;
}
let texture_upload = textures::flatten(request.textures.as_deref(), scene.meshes.len(), request.camera.target, &cancel)?;
request.textures = None; // GPU upload owns decoded pixels; release captured source bytes.
let upload = scene::flatten(&scene, request.camera.target, &cancel)?;
let bvh_ms = start.elapsed().as_secs_f64() * 1000.0;
let mut upload_bytes = (upload.geometry.len() + upload.nodes.len() + texture_upload.len()) as u64;
let environment_bytes = prepare_environment(&request, &cancel)?;
cancel.check()?;
let uploading = Instant::now();
drop(scene);
let device = &context.device;
let (uniform, accumulation, failures, readback, bind, denoiser) = checked_creation(device, || {
let make = |label, bytes: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: bytes, usage })
};
upload_bytes += environment_bytes.len() as u64;
let environment = make("path environment", &environment_bytes, wgpu::BufferUsages::STORAGE);
drop(environment_bytes);
let triangles = make("path triangles", &upload.geometry, wgpu::BufferUsages::STORAGE);
let nodes = make("path BVH", &upload.nodes, wgpu::BufferUsages::STORAGE);
let texture_buffer = make("path opaque textures", &texture_upload, wgpu::BufferUsages::STORAGE);
let uniform = make("path parameters", &[0; PARAMETER_BYTES], wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST);
let size = u64::from(request.width) * u64::from(request.height) * 16;
let accumulation = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("path accumulation"),
size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
// Inconsistent-medium path count and the first such path's location (see read_linear).
let failures = make(
"path medium failures",
&[0; FAILURE_BYTES as usize],
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
);
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("path readback"),
size: size + FAILURE_BYTES,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut entries: Vec<_> = [&uniform, &triangles, &nodes, &accumulation, &environment]
.iter()
.enumerate()
.map(|(binding, buffer)| wgpu::BindGroupEntry { binding: binding as u32, resource: buffer.as_entire_binding() })
.collect();
entries.push(wgpu::BindGroupEntry { binding: 5, resource: texture_buffer.as_entire_binding() });
entries.push(wgpu::BindGroupEntry { binding: 6, resource: failures.as_entire_binding() });
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("gpu-path"), layout: &context.layout, entries: &entries });
let denoiser = if options.denoise {
Some(denoise::Resources::new(
&context,
&uniform,
[&triangles, &nodes, &environment, &texture_buffer, &failures],
&accumulation,
size,
)?)
} else {
None
};
Ok((uniform, accumulation, failures, readback, bind, denoiser))
})?;
let submission = context.queue.submit([]);
context.wait(submission, TIMEOUT)?;
let upload_ms = uploading.elapsed().as_secs_f64() * 1000.0;
log::info!("gpu-path BVH {bvh_ms:.3} ms, upload {upload_bytes} bytes in {upload_ms:.3} ms");
cancel.check()?;
Ok(Self {
dispatch_budget: Default::default(),
context,
request,
options,
cancel,
uniform,
accumulation,
failures,
readback,
bind,
denoiser,
sample: 0,
offset: 0,
epsilon: upload.epsilon,
png: None,
started: Instant::now(),
environment: 0.0,
environment_bottom_only: false,
studio: 1.0,
#[cfg(test)]
metrics: (bvh_ms, upload_bytes, upload_ms),
})
}
fn parameters(&self, count: u32) -> Vec<u8> {
let mut bytes = Vec::with_capacity(PARAMETER_BYTES);
for x in [
self.request.width,
self.request.height,
self.sample,
self.offset,
self.options.seed,
self.options.max_depth,
count,
u32::from(self.environment_bottom_only),
] {
bytes.extend_from_slice(&x.to_le_bytes());
}
let camera = self.request.camera;
let (right, up, back) = camera.basis();
let perspective = camera.fov > 0.0;
let eye = back * (camera.distance * if perspective { 1.0 } else { 4.0 });
scene::vector(&mut bytes, eye, if perspective { 1.0 } else { 0.0 });
scene::vector(
&mut bytes,
right * camera.half_height() * (f64::from(self.request.width) / f64::from(self.request.height)),
self.options.lens.map_or(0.0, |l| l.aperture_radius_mm as f32),
);
scene::vector(&mut bytes, up * camera.half_height(), self.options.lens.map_or(0.0, |l| l.focus_distance_mm as f32));
scene::vector(&mut bytes, back, camera.distance as f32);
scene::vector(&mut bytes, Vec3::new(f64::from(self.epsilon), f64::from(self.environment), f64::from(self.studio)), 0.0);
let lighting = self.options.lighting.unwrap_or_default();
scene::vector(
&mut bytes,
Vec3::new(lighting.environment[0], lighting.environment[1], lighting.environment[2]),
if self.options.lighting.is_some() || self.request.environment.is_some() { 1.0 } else { 0.0 },
);
scene::vector(&mut bytes, Vec3::ZERO, lighting.area_lights.iter().count() as f32);
for light in lighting.area_lights.iter() {
let vector = |[x, y, z]: [f64; 3]| Vec3::new(x, y, z);
scene::vector(&mut bytes, vector(light.center) - camera.target, light.area() as f32);
scene::vector(&mut bytes, vector(light.u), 0.0);
scene::vector(&mut bytes, vector(light.v), 0.0);
scene::vector(&mut bytes, vector(light.radiance), 0.0);
}
bytes.resize(400, 0);
if let Some(source) = &self.request.environment {
let info = source.info();
scene::vector(&mut bytes, Vec3::new(info.rotation_rad.cos(), info.rotation_rad.sin(), info.intensity), 1.0);
} else {
scene::vector(&mut bytes, Vec3::ZERO, 0.0);
}
if let Some(sun) = lighting.sun {
scene::vector(&mut bytes, sun.direction(), sun.angular_radius_rad.cos() as f32);
let [r, g, b] = sun.radiance;
scene::vector(&mut bytes, Vec3::new(r, g, b), (1.0 / sun.solid_angle()) as f32);
} else {
bytes.resize(PARAMETER_BYTES, 0);
}
bytes
}
fn dispatch(&mut self) -> Result<()> {
self.cancel.check()?;
self.context.ensure_available()?;
let dispatch_started = Instant::now();
let pixels = self.request.width * self.request.height;
let count = self.dispatch_budget.count(pixels - self.offset);
let context = &self.context;
let scopes = scopes(&context.device);
context.queue.write_buffer(&self.uniform, 0, &self.parameters(count));
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("path tile") });
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("path tile"), timestamp_writes: None });
pass.set_pipeline(&context.pipeline);
pass.set_bind_group(0, &self.bind, &[]);
pass.dispatch_workgroups(count.div_ceil(64), 1, 1);
}
let submission = context.queue.submit([encoder.finish()]);
checked(scopes)?;
context.wait(submission, TIMEOUT)?;
self.dispatch_budget.observe(count, dispatch_started.elapsed());
self.cancel.check()?;
self.offset += count;
if self.offset == pixels {
self.sample += 1;
self.offset = 0;
}
Ok(())
}
#[cfg(test)]
fn complete_sample(&mut self) -> Result<()> {
let wanted = self.sample.checked_add(1).ok_or_else(|| gpu("sample counter overflow"))?;
let maximum = (self.request.width * self.request.height).div_ceil(dispatch::MIN_PATHS);
for _ in 0..maximum {
self.dispatch()?;
if self.sample == wanted {
return Ok(());
}
}
Err(gpu("bounded full-sample test helper did not advance"))
}
fn read_linear(&self) -> Result<Vec<[f32; 4]>> {
self.read_linear_buffer(&self.accumulation)
}
fn read_linear_buffer(&self, source: &wgpu::Buffer) -> Result<Vec<[f32; 4]>> {
self.cancel.check()?;
let context = &self.context;
let scopes = scopes(&context.device);
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("path readback") });
encoder.copy_buffer_to_buffer(source, 0, &self.readback, 0, self.accumulation.size());
encoder.copy_buffer_to_buffer(&self.failures, 0, &self.readback, self.accumulation.size(), FAILURE_BYTES);
let submission = context.queue.submit([encoder.finish()]);
checked(scopes)?;
let slice = self.readback.slice(..);
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 data = slice.get_mapped_range().map_err(gpu)?;
let (pixels, failures) = data.split_at(data.len().saturating_sub(FAILURE_BYTES as usize));
let word = |i: usize| failures.as_chunks::<4>().0.get(i).map_or(0, |w| u32::from_le_bytes(*w));
let failed = u64::from(word(0)) | (u64::from(word(5)) << 32);
if failed > 0 {
let paths = u64::from(self.request.width) * u64::from(self.request.height) * u64::from(self.sample.max(1));
let detail = format!("{failed} of {paths} paths, first at pixel {}, bounce {}, sample {}, code {}", word(1), word(2), word(3), word(4));
if !medium_failures_tolerated(failed, paths) {
return Err(JobError::new(
ErrorCode::InvalidScene,
format!(
"inconsistent glass medium paths ({detail}): camera must start outside volumes; overlapping solids or more than 16 nested media are unsupported"
),
));
}
log::warn!("gpu-path ended {detail} at an unclassifiable glass medium");
}
let mut values = scene::reserved((self.request.width * self.request.height) as usize)?;
for pixel in pixels.as_chunks::<16>().0 {
let mut value = [0.0; 4];
for (v, bytes) in value.iter_mut().zip(pixel.as_chunks::<4>().0) {
*v = f32::from_le_bytes(*bytes) / self.sample as f32;
if !v.is_finite() || *v < 0.0 {
return Err(JobError::new(ErrorCode::Internal, "nonfinite or negative path radiance"));
}
}
values.push(value);
}
drop(data);
self.readback.unmap();
self.cancel.check()?;
Ok(values)
}
}
#[derive(Clone, Copy)]
struct Display {
exposure: f64,
tone_map: ToneMap,
background: [f64; 3],
transparent: bool,
}
impl Display {
fn encode(self, input: &[[f32; 4]], output: &mut [u8], cancel: &CancelToken) -> Result<()> {
for (index, (pixel, rgba)) in input.iter().zip(output.as_chunks_mut::<4>().0).enumerate() {
if index % 4096 == 0 {
cancel.check()?;
}
let alpha = f64::from(pixel[3]).clamp(0.0, 1.0);
for ((radiance, background), out) in pixel.iter().take(3).zip(self.background).zip(rgba.iter_mut()) {
let c = if alpha > 0.0 { f64::from(*radiance) * self.exposure / alpha } else { 0.0 };
let mapped = self.tone_map.map(c);
let value = if self.transparent { mapped } else { mapped * alpha + background * (1.0 - alpha) };
let encoded = if value <= 0.0031308 { value * 12.92 } else { 1.055 * value.powf(1.0 / 2.4) - 0.055 };
*out = (encoded.clamp(0.0, 1.0) * 255.0).round() as u8;
}
rgba[3] = if self.transparent { (alpha * 255.0).round() as u8 } else { 255 };
}
Ok(())
}
}
impl ImageWork for Task {
fn step(&mut self, units: usize) -> Result<TaskPoll> {
self.cancel.check()?;
self.context.ensure_available()?;
if let Some(png) = &mut self.png {
return png.step(units);
}
if self.sample < self.options.samples {
self.dispatch()?;
return Ok(TaskPoll::Pending(Progress { phase: Phase::Running, done: u64::from(self.sample), total: u64::from(self.options.samples) }));
}
log::info!("gpu-path {} spp, {:.3} ms/spp", self.sample, self.started.elapsed().as_secs_f64() * 1000.0 / f64::from(self.sample));
let mut linear = self.read_linear()?;
if let Some(denoiser) = &self.denoiser {
// Diagnose the raw path buffer before filtering: never hide invalid media
// or nonfinite radiance by replacing a pixel with neighbouring values.
drop(linear);
denoiser.run(self)?;
linear = self.read_linear_buffer(denoiser.output())?;
}
let mut rgba = scene::reserved(linear.len() * 4)?;
rgba.resize(linear.len() * 4, 0);
let display = Display {
exposure: 2.0f64.powf(self.options.exposure_ev),
tone_map: self.options.tone_map,
background: self.request.background.map(solidcraft_render::surface::srgb8_to_linear),
transparent: self.request.transparent_background,
};
// Pixels are independent: encode contiguous chunks on every core.
let workers = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16);
let chunk = linear.len().div_ceil(workers).max(1);
let cancel = &self.cancel;
std::thread::scope(|scope| {
let jobs: Vec<_> = linear
.chunks(chunk)
.zip(rgba.chunks_mut(chunk * 4))
.map(|(input, output)| scope.spawn(move || display.encode(input, output, cancel)))
.collect();
jobs.into_iter()
.try_for_each(|job| job.join().unwrap_or_else(|_| Err(JobError::new(ErrorCode::Internal, "display encoding worker failed"))))
})?;
self.png = Some(PngTask::new(&self.request, rgba, self.cancel.clone())?);
Ok(TaskPoll::Pending(Progress { phase: Phase::Encoding, done: 0, total: u64::from(self.request.height) }))
}
}
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
//! Exact connected planar patches of the positions actually uploaded to the GPU.
//! This is a source-plane identity, not an angular or distance tolerance. A ray
//! leaving a plane cannot meet that same plane again at a positive distance.
use super::{Triangle, invalid, medium_root, reserved};
use robust::{Coord, Coord3D, orient2d, orient3d};
use solidcraft_render::job::{CancelToken, Result};
#[derive(Eq, PartialEq, Ord, PartialOrd)]
struct Edge {
body: u32,
a: [u32; 3],
b: [u32; 3],
triangle: usize,
}
pub(super) fn temporary_bytes_per_triangle() -> usize {
3 * std::mem::size_of::<Edge>() + std::mem::size_of::<usize>() + std::mem::size_of::<u32>()
}
fn uploaded(t: &Triangle) -> [Coord3D<f64>; 3] {
t.p.map(|p| Coord3D { x: f64::from(p.x as f32), y: f64::from(p.y as f32), z: f64::from(p.z as f32) })
}
fn key(p: Coord3D<f64>) -> [u32; 3] {
[p.x, p.y, p.z].map(|x| if x == 0.0 { 0 } else { (x as f32).to_bits() })
}
fn nondegenerate(p: [Coord3D<f64>; 3]) -> bool {
for axes in [(0, 1), (0, 2), (1, 2)] {
let q = p.map(|v| {
let values = [v.x, v.y, v.z];
Coord { x: values[axes.0], y: values[axes.1] }
});
if orient2d(q[0], q[1], q[2]) != 0.0 {
return true;
}
}
false
}
pub(super) fn ids(triangles: &[Triangle], cancel: &CancelToken) -> Result<Vec<u32>> {
cancel.check()?;
if triangles.len() >= u32::MAX as usize {
return Err(invalid("too many triangles for planar patch identities"));
}
let edge_count = triangles.len().checked_mul(3).ok_or_else(|| invalid("planar edge count overflow"))?;
let mut edges = reserved::<Edge>(edge_count)?;
let mut parents = reserved::<usize>(triangles.len())?;
parents.extend(0..triangles.len());
for (index, triangle) in triangles.iter().enumerate() {
if index % 1024 == 0 {
cancel.check()?;
}
let p = uploaded(triangle);
// An f64 triangle can collapse at upload precision. It has no valid plane
// and must never connect otherwise unrelated planes into one patch.
if !nondegenerate(p) {
continue;
}
let keys = p.map(key);
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
let (a, b) = if keys[a] <= keys[b] { (keys[a], keys[b]) } else { (keys[b], keys[a]) };
edges.push(Edge { body: triangle.medium, a, b, triangle: index });
}
}
cancel.check()?;
edges.sort_unstable();
cancel.check()?;
let mut begin = 0;
while begin < edges.len() {
if begin % 1024 < 3 {
cancel.check()?;
}
let first = edges.get(begin).ok_or_else(|| invalid("missing planar edge"))?;
let mut end = begin + 1;
while let Some(edge) = edges.get(end) {
if (edge.body, edge.a, edge.b) != (first.body, first.a, first.b) {
break;
}
if end % 1024 == 0 {
cancel.check()?;
}
end += 1;
}
// Only unambiguous two-face adjacency is joined. At nonmanifold edges
// leave identities separate rather than inferring a connected surface.
if end - begin == 2 {
let second = edges.get(begin + 1).ok_or_else(|| invalid("missing planar neighbor"))?;
let a = uploaded(triangles.get(first.triangle).ok_or_else(|| invalid("missing planar triangle"))?);
let b = uploaded(triangles.get(second.triangle).ok_or_else(|| invalid("missing planar triangle"))?);
// Adaptive exact predicates on f32-as-f64 coordinates. Mirrored
// winding does not change plane identity; curved facets do.
if b.iter().all(|&p| orient3d(a[0], a[1], a[2], p) == 0.0) {
let root_a = medium_root(&mut parents, first.triangle)?;
let root_b = medium_root(&mut parents, second.triangle)?;
let (keep, merge) = (root_a.min(root_b), root_a.max(root_b));
*parents.get_mut(merge).ok_or_else(|| invalid("missing planar component"))? = keep;
}
}
begin = end;
}
let mut result = reserved(triangles.len())?;
for index in 0..triangles.len() {
if index % 1024 == 0 {
cancel.check()?;
}
result.push(medium_root(&mut parents, index)? as u32 + 1);
}
cancel.check()?;
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Vec3;
use solidcraft_render::surface::OpaqueSurface;
fn triangle(p: [[f64; 3]; 3], body: u32) -> Triangle {
Triangle {
p: p.map(|v| Vec3::new(v[0], v[1], v[2])),
indices: [0, 1, 2],
surface: OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, 1.5).unwrap(),
transmission: None,
medium: body,
}
}
#[test]
fn exact_connected_planes_join_with_either_winding() {
let a = [191.3_f32 as f64, 79.2_f32 as f64, 84.7_f32 as f64];
let b = [a[0] + 8.0, a[1], a[2] + 2.0];
let c = [a[0] + 8.0, a[1] + 8.0, a[2] + 6.0];
let d = [a[0], a[1] + 8.0, a[2] + 4.0];
for neighbor in [[a, c, d], [d, c, a]] {
let values = ids(&[triangle([a, b, c], 1), triangle(neighbor, 1)], &CancelToken::default()).unwrap();
assert_eq!(values[0], values[1]);
}
}
#[test]
fn disconnected_parallel_bent_and_different_bodies_stay_separate() {
let a = [0.0, 0.0, 0.0];
let b = [1.0, 0.0, 0.0];
let c = [1.0, 1.0, 0.0];
let d = [0.0, 1.0, 0.0];
let cases = [
(triangle([a, c, d], 2), "different bodies"),
(triangle([[2.0, 0.0, 0.0], [3.0, 0.0, 0.0], [2.0, 1.0, 0.0]], 1), "disconnected"),
(triangle([a, c, [0.0, 1.0, 1e-20]], 1), "arbitrarily shallow bend"),
(triangle([[0.0, 0.0, 1e-20], [1.0, 1.0, 1e-20], [0.0, 1.0, 1e-20]], 1), "near parallel"),
];
for (neighbor, name) in cases {
let values = ids(&[triangle([a, b, c], 1), neighbor], &CancelToken::default()).unwrap();
assert_ne!(values[0], values[1], "{name}");
}
}
#[test]
fn grouping_uses_upload_precision_and_normalizes_signed_zero() {
let values = ids(
&[
triangle([[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0]], 1),
triangle([[-0.0, -0.0, 1.0 + 1e-10], [1.0, 1.0, 1.0 + 1e-10], [0.0, 1.0, 1.0]], 1),
],
&CancelToken::default(),
)
.unwrap();
assert_eq!(values[0], values[1]);
}
#[test]
fn cancelled_and_empty_preparation_are_bounded() {
let cancel = CancelToken::default();
assert!(ids(&[], &cancel).unwrap().is_empty());
cancel.cancel();
assert!(ids(&[], &cancel).is_err());
assert!(temporary_bytes_per_triangle() >= 3 * std::mem::size_of::<Edge>() + 12);
}
#[test]
fn upload_collapse_never_bridges_planes() {
let a = [1.0, 0.0, 0.0];
let b = [1.0 + 1e-10, 0.0, 0.0];
let c = [1.0, 1.0, 0.0];
let values =
ids(&[triangle([a, c, [2.0, 0.0, 0.0]], 1), triangle([a, b, c], 1), triangle([a, c, [1.0, 0.0, 1.0]], 1)], &CancelToken::default())
.unwrap();
assert_ne!(values[0], values[1]);
assert_ne!(values[1], values[2]);
assert_ne!(values[0], values[2]);
}
#[test]
fn packing_offset_and_ids_follow_bvh_reordering() {
use solidcraft_geom::Mesh;
use solidcraft_render::{ProductMesh, ProductScene};
use std::sync::Arc;
let mut mesh = Mesh::default();
for z in [5.0, 0.0, 10.0] {
let first = mesh.positions.len() as u32;
mesh.positions.extend([Vec3::new(0.0, 0.0, z), Vec3::new(1.0, 0.0, z), Vec3::new(1.0, 1.0, z), Vec3::new(0.0, 1.0, z)]);
mesh.normals.extend([Vec3::Z; 4]);
mesh.triangles.extend([[first, first + 1, first + 2], [first, first + 2, first + 3]]);
mesh.tri_face.extend([0, 0]);
}
let bounds = mesh.bounds();
let scene = ProductScene {
bounds,
meshes: vec![ProductMesh {
name: "three separate planes".into(),
mesh: Arc::new(mesh),
color: [255; 3],
opacity: 1.0,
surface: None,
transmission: None,
faces: vec![],
}],
};
let upload = super::super::flatten(&scene, Vec3::ZERO, &CancelToken::default()).unwrap();
let word = |index: usize| u32::from_le_bytes(upload.geometry[index * 4..index * 4 + 4].try_into().unwrap()) as usize;
assert_eq!(word(0), 6);
assert_eq!(word(3), word(2) + 6 * 16);
assert_eq!(upload.geometry.len(), 16 + 6 * 84 + 12 * 32);
let mut pairs = Vec::new();
for index in 0..6 {
let vertex = word(4 + index * 4);
let z = f32::from_bits(word(word(1) + vertex * 8 + 2) as u32);
pairs.push((z, word(word(3) + index)));
}
for (a, pa) in &pairs {
for (b, pb) in &pairs {
assert_eq!(a == b, pa == pb);
}
}
assert!(!upload.nodes.is_empty());
assert!(upload.epsilon > 0.0);
}
}
+526
View File
@@ -0,0 +1,526 @@
//! Original median-split BVH. World geometry is rebased around the camera target for f32.
#[path = "planar_patches.rs"]
mod planar_patches;
use std::collections::HashMap;
use solidcraft_geom::Vec3;
use solidcraft_render::{
ProductScene,
job::{CancelToken, ErrorCode, JobError, Result},
surface::{OpaqueSurface, srgb8_to_linear},
transmission::Transmission,
};
pub(super) struct Triangle {
pub p: [Vec3; 3],
pub indices: [u32; 3],
pub surface: OpaqueSurface,
pub transmission: Option<Transmission>,
pub medium: u32,
}
type MediumEdge = (usize, usize, i32);
pub(super) fn medium_preparation_bytes(scene: &ProductScene) -> u64 {
// Validation consumes one body's scratch at a time. Opaque/thin-sheet bodies
// allocate none of the weld table: do not charge their triangles for it.
let closed = |t: Option<Transmission>| t.is_some_and(|v| v.thin_wall_thickness_mm().is_none());
let mut total = 0u64;
let mut largest_medium = 0u64;
for body in &scene.meshes {
let triangles = body.mesh.triangles.len() as u64;
total = total.saturating_add(triangles);
if closed(body.transmission) || body.faces.iter().any(|face| closed(face.transmission)) {
largest_medium = largest_medium.max(triangles);
}
}
// Three representatives per triangle at most. The 320 includes positions,
// full eight-slot hash entries, control bytes and worst-case table rounding.
let scratch = 3 * (std::mem::size_of::<MediumEdge>() + 320) + std::mem::size_of::<usize>();
total.saturating_mul(std::mem::size_of::<Triangle>() as u64).saturating_add(largest_medium.saturating_mul(scratch as u64))
}
pub(super) fn planar_preparation_bytes_per_triangle() -> usize {
let common = std::mem::size_of::<Triangle>() + 2 * std::mem::size_of::<Node>();
(common + planar_patches::temporary_bytes_per_triangle()).max(common + 84 + 64 + 4)
}
impl Triangle {
fn centre(&self, axis: usize) -> f64 {
self.p.iter().map(|p| [p.x, p.y, p.z].get(axis).copied().unwrap_or(0.0)).sum::<f64>() / 3.0
}
}
#[derive(Default)]
struct Node {
lo: [f32; 3],
hi: [f32; 3],
first: u32,
count: u32,
}
pub(super) struct Upload {
pub geometry: Vec<u8>,
pub nodes: Vec<u8>,
pub epsilon: f32,
}
fn invalid(message: &str) -> JobError {
JobError::new(ErrorCode::InvalidScene, message)
}
pub(super) fn reserved<T>(count: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "path tracer allocation failed"))?;
Ok(v)
}
pub(super) fn flatten(scene: &ProductScene, origin: Vec3, cancel: &CancelToken) -> Result<Upload> {
let count = scene.meshes.iter().map(|m| m.mesh.triangles.len()).sum::<usize>();
let mut triangles = reserved(count)?;
let vertex_count = scene.meshes.iter().map(|m| m.mesh.positions.len()).sum::<usize>();
let mut vertices = reserved(vertex_count * 32)?;
let mut vertex_offset = 0u32;
let mut media_bounds = reserved::<(Vec3, Vec3, bool)>(8)?;
for (body_index, body) in scene.meshes.iter().enumerate() {
cancel.check()?;
let body_start = triangles.len();
let mut faces = reserved(body.faces.len())?;
faces.extend(body.faces.iter());
faces.sort_unstable_by_key(|f| f.face);
if faces.windows(2).any(|f| f[0].face == f[1].face) {
return Err(invalid("duplicate face appearance"));
}
let mesh = &body.mesh;
if mesh.normals.len() != mesh.positions.len() || mesh.tri_face.len() != mesh.triangles.len() {
return Err(invalid("path tracer requires normals and a face ID for every triangle"));
}
for (index, (position, normal)) in mesh.positions.iter().zip(&mesh.normals).enumerate() {
if index % 1024 == 0 {
cancel.check()?;
}
let position = *position - origin;
if !position.is_finite() || [position.x, position.y, position.z].iter().any(|x| x.abs() > 1e7) {
return Err(invalid("path geometry must be finite and within 1e7 mm of the camera target"));
}
let normal = solidcraft_render::surface::unit_direction(*normal).map_err(|_| invalid("invalid mesh normal"))?;
vector(&mut vertices, position, 0.0);
vector(&mut vertices, normal, 0.0);
}
for (index, indices) in mesh.triangles.iter().enumerate() {
if index % 1024 == 0 {
cancel.check()?;
}
let face = mesh.tri_face.get(index).copied().ok_or_else(|| invalid("missing triangle face"))?;
let look = faces.binary_search_by_key(&face, |f| f.face).ok().and_then(|i| faces.get(i));
let opacity = look.map_or(body.opacity, |f| f.opacity);
let transmission = look.map_or(body.transmission, |f| f.transmission);
if opacity != 1.0 && transmission.is_none() {
return Err(invalid("gpu-path requires explicit transmission for nonopaque preview appearances"));
}
let color = look.map_or(body.color, |f| f.color);
let surface = look
.map_or(body.surface, |f| f.surface)
.map(Ok)
.unwrap_or_else(|| {
OpaqueSurface::new(Vec3::new(srgb8_to_linear(color[0]), srgb8_to_linear(color[1]), srgb8_to_linear(color[2])), 0.0, 0.5, 1.5)
})
.map_err(|e| invalid(&e.to_string()))?;
let mut p = [Vec3::ZERO; 3];
let mut global_indices = [0u32; 3];
for (corner, &vertex) in indices.iter().enumerate() {
let position = *mesh.positions.get(vertex as usize).ok_or_else(|| invalid("triangle index out of bounds"))? - origin;
if !position.is_finite() || [position.x, position.y, position.z].iter().any(|x| x.abs() > 1e7) {
return Err(invalid("path geometry must be finite and within 1e7 mm of the camera target"));
}
if let Some(v) = p.get_mut(corner) {
*v = position;
}
if let Some(v) = global_indices.get_mut(corner) {
*v = vertex.checked_add(vertex_offset).ok_or_else(|| invalid("vertex offset overflow"))?;
}
}
if (p[1] - p[0]).cross(p[2] - p[0]).len() <= 1e-12 {
continue;
}
if transmission.is_some() && surface.clear_coat().is_some() {
return Err(invalid("clear coat and transmission cannot share a surface"));
}
if transmission.is_some() && surface.metallic() != 0.0 {
return Err(invalid("transmission requires a nonmetallic surface"));
}
triangles.push(Triangle { p, indices: global_indices, surface, transmission, medium: body_index as u32 + 1 });
}
validate_medium(&triangles[body_start..], cancel)?;
let body_triangles = &triangles[body_start..];
if body_triangles.iter().any(|t| t.transmission.is_some()) {
if media_bounds.len() >= 8 {
return Err(invalid("at most eight dielectric bodies are supported"));
}
let closed = body_triangles.iter().any(|t| t.transmission.is_some_and(|v| v.thin_wall_thickness_mm().is_none()));
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = -lo;
for t in body_triangles {
for p in t.p {
lo = lo.min(p);
hi = hi.max(p);
}
}
for (other_lo, other_hi, other_closed) in &media_bounds {
if (closed || *other_closed)
&& !(hi.x < other_lo.x || lo.x > other_hi.x || hi.y < other_lo.y || lo.y > other_hi.y || hi.z < other_lo.z || lo.z > other_hi.z)
{
return Err(invalid("nested or overlapping dielectric bounds are unsupported; separate the bodies' bounds"));
}
}
media_bounds.push((lo, hi, closed));
}
vertex_offset = vertex_offset.checked_add(mesh.positions.len() as u32).ok_or_else(|| invalid("vertex offset overflow"))?;
}
let mut nodes = reserved(triangles.len().saturating_mul(2).max(1))?;
nodes.push(Node::default());
build(&mut triangles, 0, &mut nodes, 0, 0, cancel)?;
let patches = planar_patches::ids(&triangles, cancel)?;
let extent = nodes.first().map_or(1.0, |n| n.lo.iter().zip(n.hi).map(|(a, b)| b - a).fold(0.0f32, f32::max));
let mut geometry = reserved(16 + triangles.len().max(1) * 84 + vertices.len())?;
let mut materials = reserved(triangles.len().max(1) * 64)?;
let vertex_word = 4 + triangles.len().max(1) * 4;
let material_word = vertex_word + vertices.len() / 4;
let patch_word = material_word + triangles.len().max(1) * 16;
for word in [triangles.len() as u32, vertex_word as u32, material_word as u32, patch_word as u32] {
geometry.extend_from_slice(&word.to_le_bytes());
}
for (material, t) in triangles.into_iter().enumerate() {
if material % 1024 == 0 {
cancel.check()?;
}
for index in t.indices {
geometry.extend_from_slice(&index.to_le_bytes());
}
geometry.extend_from_slice(&(material as u32).to_le_bytes());
vector(&mut materials, t.surface.base_color(), if t.transmission.is_some() { 1.0 } else { 0.0 });
vector(
&mut materials,
Vec3::new(t.surface.metallic(), t.surface.roughness(), t.surface.dielectric_ior()),
t.transmission.and_then(|t| t.thin_wall_thickness_mm()).unwrap_or(-1.0) as f32,
);
vector(&mut materials, t.transmission.map_or(Vec3::ZERO, |t| t.extinction()), t.medium as f32);
let coat = t.surface.clear_coat().map_or(Vec3::new(0.0, 0.5, 1.5), |c| Vec3::new(c.weight(), c.roughness(), c.ior()));
vector(&mut materials, coat, if t.transmission.is_some_and(|t| t.shadow_transmission()) { 1.0 } else { 0.0 });
}
if geometry.len() == 16 {
geometry.resize(32, 0);
materials.resize(64, 0);
}
geometry.extend_from_slice(&vertices);
drop(vertices);
geometry.extend_from_slice(&materials);
drop(materials);
if patches.is_empty() {
geometry.extend_from_slice(&0u32.to_le_bytes());
} else {
for (index, patch) in patches.into_iter().enumerate() {
if index % 1024 == 0 {
cancel.check()?;
}
geometry.extend_from_slice(&patch.to_le_bytes());
}
}
let mut node_bytes = reserved(nodes.len() * 32)?;
for node in nodes {
for x in node.lo {
node_bytes.extend_from_slice(&x.to_le_bytes());
}
node_bytes.extend_from_slice(&node.first.to_le_bytes());
for x in node.hi {
node_bytes.extend_from_slice(&x.to_le_bytes());
}
node_bytes.extend_from_slice(&node.count.to_le_bytes());
}
Ok(Upload { geometry, nodes: node_bytes, epsilon: (extent * 1e-7).max(1e-5) })
}
const MEDIUM_WELD_MM: f64 = 1e-6;
#[derive(Default)]
struct MediumBucket {
representatives: [usize; 8],
len: usize,
}
/// Only topology uses these representatives; every uploaded vertex stays unchanged.
/// Checking neighboring cells avoids rounding-boundary cracks in curved CAD seams.
struct MediumWeld {
cells: HashMap<[i64; 3], MediumBucket>,
positions: Vec<Vec3>,
limit: usize,
}
impl MediumWeld {
fn new(limit: usize) -> Result<Self> {
let mut cells = HashMap::new();
cells.try_reserve(limit).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "glass topology allocation failed"))?;
Ok(Self { cells, positions: reserved(limit)?, limit })
}
fn key(&mut self, point: Vec3) -> Result<usize> {
let xyz = [point.x, point.y, point.z];
// Leave room for neighboring cells and refuse saturated integer casts.
if !xyz.into_iter().all(|v| v.is_finite() && v.abs() <= 1e9) {
return Err(invalid("glass topology position exceeds finite coordinate bounds"));
}
let cell = xyz.map(|v| (v / MEDIUM_WELD_MM).floor() as i64);
let mut found: Option<usize> = None;
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
let neighbor = [cell[0] + dx, cell[1] + dy, cell[2] + dz];
let Some(bucket) = self.cells.get(&neighbor) else { continue };
for &index in bucket.representatives.iter().take(bucket.len) {
let other = self.positions.get(index).ok_or_else(|| invalid("missing glass topology representative"))?;
let delta = point - *other;
if delta.dot(delta) <= MEDIUM_WELD_MM * MEDIUM_WELD_MM && found.is_none_or(|old| index < old) {
found = Some(index);
}
}
}
}
}
if let Some(index) = found {
return Ok(index);
}
if self.positions.len() >= self.limit {
return Err(invalid("glass topology representative budget exceeded"));
}
let index = self.positions.len();
let bucket = self.cells.entry(cell).or_default();
// Split a unit cell into eight half-size cubes: points sharing one are
// at most sqrt(3)/2 tolerances apart, so a cell has at most eight reps.
let slot = bucket.representatives.get_mut(bucket.len).ok_or_else(|| invalid("glass topology cell budget exceeded"))?;
*slot = index;
bucket.len += 1;
self.positions.push(point);
Ok(index)
}
}
// Volume transmission needs a consistently oriented closed two-manifold. CAD
// tessellation duplicates face-seam vertices; compare their actual Euclidean
// separation at the existing one-nanometre-in-mm tolerance, without modifying
// uploaded geometry or merging chains beyond a representative's tolerance.
fn validate_medium(triangles: &[Triangle], cancel: &CancelToken) -> Result<()> {
let Some(first) = triangles.iter().find(|t| t.transmission.is_some_and(|v| v.thin_wall_thickness_mm().is_none())) else {
return Ok(());
};
if triangles.iter().any(|t| t.transmission != first.transmission || t.surface.dielectric_ior() != first.surface.dielectric_ior()) {
return Err(invalid("a closed glass body must use one homogeneous transmission medium; use separate bodies or thin walls"));
}
let vertex_count = triangles.len().checked_mul(3).ok_or_else(|| invalid("glass topology vertex count overflow"))?;
let mut edges = reserved::<MediumEdge>(vertex_count)?;
let mut weld = MediumWeld::new(vertex_count)?;
let mut parents = reserved::<usize>(triangles.len())?;
parents.extend(0..triangles.len());
let origin = first.p[0];
let mut six_volume = 0.0;
for (index, triangle) in triangles.iter().enumerate() {
if index % 1024 == 0 {
cancel.check()?;
}
let keys = [weld.key(triangle.p[0])?, weld.key(triangle.p[1])?, weld.key(triangle.p[2])?];
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
if keys[a] == keys[b] {
return Err(invalid("glass triangle collapses at the 1e-6 mm topology tolerance"));
}
let ordinal = i32::try_from(index + 1).map_err(|_| invalid("too many glass triangles"))?;
edges.push(if keys[a] < keys[b] { (keys[a], keys[b], ordinal) } else { (keys[b], keys[a], -ordinal) });
}
six_volume += (triangle.p[0] - origin).dot((triangle.p[1] - origin).cross(triangle.p[2] - origin));
}
edges.sort_unstable();
for edge in edges.chunks(2) {
let [a, b] = edge else {
return Err(invalid("volume glass requires a closed mesh; use thin_wall_thickness_mm for sheets"));
};
if a.0 != b.0 || a.1 != b.1 || a.2.signum() + b.2.signum() != 0 {
return Err(invalid("volume glass requires a closed, consistently outward-oriented mesh"));
}
let first = medium_root(&mut parents, a.2.unsigned_abs() as usize - 1)?;
let second = medium_root(&mut parents, b.2.unsigned_abs() as usize - 1)?;
*parents.get_mut(first).ok_or_else(|| invalid("invalid glass component"))? = second;
}
// Groups with four or more coincident directed edges cannot be a two-manifold.
if edges.windows(3).any(|e| e[0].0 == e[2].0 && e[0].1 == e[2].1) {
return Err(invalid("nonmanifold glass edge"));
}
let component = medium_root(&mut parents, 0)?;
for index in 0..parents.len() {
if index % 1024 == 0 {
cancel.check()?;
}
if medium_root(&mut parents, index)? != component {
return Err(invalid("closed dielectric body must contain exactly one connected shell"));
}
}
if six_volume <= 0.0 || !six_volume.is_finite() {
return Err(invalid("glass mesh must enclose positive outward-oriented volume"));
}
cancel.check()
}
fn medium_root(parents: &mut [usize], mut index: usize) -> Result<usize> {
loop {
let parent = *parents.get(index).ok_or_else(|| invalid("invalid glass component index"))?;
if parent == index {
return Ok(index);
}
let grandparent = *parents.get(parent).ok_or_else(|| invalid("invalid glass component parent"))?;
*parents.get_mut(index).ok_or_else(|| invalid("invalid glass component index"))? = grandparent;
index = grandparent;
}
}
// The shader's traversal stack holds 32 nodes and needs at most depth + 1.
const MAX_BVH_DEPTH: usize = 30;
const SAH_BINS: usize = 16;
type Bounds = ([f32; 3], [f32; 3]);
const EMPTY_BOUNDS: Bounds = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
fn grow(bounds: &mut Bounds, t: &Triangle) {
for p in t.p {
for (axis, value) in [p.x as f32, p.y as f32, p.z as f32].into_iter().enumerate() {
bounds.0[axis] = bounds.0[axis].min(value);
bounds.1[axis] = bounds.1[axis].max(value);
}
}
}
fn merge(bounds: &mut Bounds, other: &Bounds) {
for axis in 0..3 {
bounds.0[axis] = bounds.0[axis].min(other.0[axis]);
bounds.1[axis] = bounds.1[axis].max(other.1[axis]);
}
}
fn half_area(bounds: &Bounds) -> f64 {
let [x, y, z] = [0, 1, 2].map(|axis| f64::from((bounds.1[axis] - bounds.0[axis]).max(0.0)));
x * y + y * z + z * x
}
// Depth a median split adds below a node of `count` triangles.
fn median_levels(mut count: usize) -> usize {
let mut levels = 0;
while count > 4 {
count = count.div_ceil(2);
levels += 1;
}
levels
}
// Binned surface-area heuristic over triangle centres. A split is accepted only
// if a median split of its larger side still fits MAX_BVH_DEPTH, so the median
// fallback always remains available. Returns the left count after partitioning.
fn sah_split(triangles: &mut [Triangle], depth: usize) -> Option<usize> {
let mut centre_lo = [f64::INFINITY; 3];
let mut centre_hi = [f64::NEG_INFINITY; 3];
for t in triangles.iter() {
for axis in 0..3 {
centre_lo[axis] = centre_lo[axis].min(t.centre(axis));
centre_hi[axis] = centre_hi[axis].max(t.centre(axis));
}
}
let bin = |t: &Triangle, axis: usize| {
let scaled = (t.centre(axis) - centre_lo[axis]) / (centre_hi[axis] - centre_lo[axis]) * SAH_BINS as f64;
(scaled as usize).min(SAH_BINS - 1)
};
let mut best: Option<(f64, usize, usize)> = None;
for axis in 0..3 {
if centre_hi[axis] - centre_lo[axis] <= 0.0 {
continue;
}
let mut counts = [0usize; SAH_BINS];
let mut boxes = [EMPTY_BOUNDS; SAH_BINS];
for t in triangles.iter() {
let b = bin(t, axis);
counts[b] += 1;
grow(&mut boxes[b], t);
}
let mut right_area = [0.0; SAH_BINS];
let mut right_count = [0usize; SAH_BINS];
let mut accumulated = EMPTY_BOUNDS;
let mut count = 0;
for b in (1..SAH_BINS).rev() {
merge(&mut accumulated, &boxes[b]);
count += counts[b];
right_area[b] = half_area(&accumulated);
right_count[b] = count;
}
accumulated = EMPTY_BOUNDS;
count = 0;
for b in 1..SAH_BINS {
merge(&mut accumulated, &boxes[b - 1]);
count += counts[b - 1];
let right = right_count[b];
if count == 0 || right == 0 || depth + 1 + median_levels(count.max(right)) > MAX_BVH_DEPTH {
continue;
}
let cost = half_area(&accumulated) * count as f64 + right_area[b] * right as f64;
if best.is_none_or(|(lowest, _, _)| cost < lowest) {
best = Some((cost, axis, b));
}
}
}
let (_, axis, split) = best?;
let mut middle = 0;
for i in 0..triangles.len() {
if bin(&triangles[i], axis) < split {
triangles.swap(i, middle);
middle += 1;
}
}
Some(middle)
}
fn build(triangles: &mut [Triangle], offset: usize, nodes: &mut Vec<Node>, index: usize, depth: usize, cancel: &CancelToken) -> Result<()> {
cancel.check()?;
let mut bounds = EMPTY_BOUNDS;
for t in triangles.iter() {
grow(&mut bounds, t);
}
let (lo, hi) = bounds;
let mut node = Node { lo, hi, first: offset as u32, count: triangles.len() as u32 };
if triangles.is_empty() {
node = Node { first: u32::MAX, ..Default::default() };
}
if triangles.len() > 4 {
let middle = match sah_split(triangles, depth) {
Some(middle) => middle,
None => {
let axis = (0..3).max_by(|&a, &b| (hi[a] - lo[a]).total_cmp(&(hi[b] - lo[b]))).unwrap_or(0);
let middle = triangles.len() / 2;
triangles.select_nth_unstable_by(middle, |a, b| a.centre(axis).total_cmp(&b.centre(axis)));
middle
}
};
let first = nodes.len();
nodes.push(Node::default());
nodes.push(Node::default());
node.first = first as u32;
node.count = 0;
let (left, right) = triangles.split_at_mut(middle);
build(left, offset, nodes, first, depth + 1, cancel)?;
build(right, offset + middle, nodes, first + 1, depth + 1, cancel)?;
}
*nodes.get_mut(index).ok_or_else(|| invalid("missing BVH node"))? = node;
Ok(())
}
pub(super) fn vector(bytes: &mut Vec<u8>, v: Vec3, w: f32) {
for value in [v.x as f32, v.y as f32, v.z as f32, w] {
bytes.extend_from_slice(&value.to_le_bytes());
}
}
#[cfg(test)]
#[path = "scene_weld_tests.rs"]
mod weld_tests;
#[cfg(test)]
#[path = "scene_bvh_tests.rs"]
mod bvh_tests;
+62
View File
@@ -0,0 +1,62 @@
use super::{MAX_BVH_DEPTH, Node, Triangle, build};
use solidcraft_geom::Vec3;
use solidcraft_render::{job::CancelToken, surface::OpaqueSurface};
// Triangle centres spread over 60 decades: a sixteen-bin SAH split on the
// centre range peels only the few largest triangles off at each level, so an
// unbounded build is far deeper than the shader's traversal stack allows.
fn decades(count: usize) -> Vec<Triangle> {
let surface = OpaqueSurface::new(Vec3::new(0.5, 0.5, 0.5), 0.0, 0.5, 1.5).unwrap();
(0..count)
.map(|i| {
let x = 10f64.powf(-30.0 + 60.0 * i as f64 / count as f64);
Triangle {
p: [Vec3::new(x, 0., 0.), Vec3::new(x * 1.5, 0., 0.), Vec3::new(x, x, x)],
indices: [0, 1, 2].map(|c| (3 * i + c) as u32),
surface,
transmission: None,
medium: 0,
}
})
.collect()
}
#[test]
fn sah_tree_respects_the_shader_stack_depth_and_covers_every_triangle_once() {
let count = 2048;
let mut triangles = decades(count);
let mut nodes = vec![Node::default()];
build(&mut triangles, 0, &mut nodes, 0, 0, &CancelToken::default()).unwrap();
assert!(nodes.len() < 2 * count, "{} nodes", nodes.len());
let mut covered = vec![0; count];
let mut deepest = 0;
let mut pending = vec![(0usize, 0usize)];
while let Some((index, depth)) = pending.pop() {
let node = &nodes[index];
let (first, size) = (node.first as usize, node.count as usize);
deepest = deepest.max(depth);
if size == 0 {
for child in [first, first + 1] {
let inner = &nodes[child];
for axis in 0..3 {
assert!(node.lo[axis] <= inner.lo[axis] && inner.hi[axis] <= node.hi[axis], "child box escapes its parent");
}
pending.push((child, depth + 1));
}
continue;
}
assert!(size <= 4);
for (slot, triangle) in covered[first..first + size].iter_mut().zip(&triangles[first..first + size]) {
*slot += 1;
for p in triangle.p {
for (axis, value) in [p.x as f32, p.y as f32, p.z as f32].into_iter().enumerate() {
assert!(node.lo[axis] <= value && value <= node.hi[axis], "triangle escapes its leaf");
}
}
}
}
assert!(covered.iter().all(|&c| c == 1), "{covered:?}");
assert!(deepest <= MAX_BVH_DEPTH, "depth {deepest}");
// Covering 2048 triangles in leaves of four needs at least nine levels.
assert!(deepest >= 9, "depth {deepest}");
}
+87
View File
@@ -0,0 +1,87 @@
use std::sync::Arc;
use super::{MediumWeld, Triangle, flatten, medium_preparation_bytes};
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_render::{ProductMesh, ProductScene, job::CancelToken, surface::OpaqueSurface, transmission::Transmission};
fn seam_tetra(offset: Vec3) -> ProductScene {
// Original outward tetrahedron. Only one face has a duplicated CAD seam
// endpoint; its measured displacement comes from our revolved tumbler.
let origin = Vec3::new(-81.00000000023796, -51.6269330409162, 592.0);
let mesh = Mesh {
positions: vec![origin, origin + Vec3::X, origin + Vec3::Y, origin + Vec3::Z, origin + offset],
normals: vec![Vec3::Z; 5],
triangles: vec![[4, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]],
tri_face: vec![0; 4],
..Default::default()
};
let bounds = mesh.bounds();
ProductScene {
meshes: vec![ProductMesh {
name: "original seam tetrahedron".into(),
mesh: Arc::new(mesh),
color: [255; 3],
opacity: 1.0,
surface: Some(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.1, 1.5).unwrap()),
transmission: Some(Transmission::new(Vec3::ZERO, None).unwrap()),
faces: Vec::new(),
}],
bounds,
}
}
#[test]
fn closed_cad_seam_across_rounding_cells_keeps_one_nanometre_tolerance() {
let delta = Vec3::new(-0.00000054155099, 0.00000031266462, 0.0);
assert!(delta.len() < 1e-6);
let scene = seam_tetra(delta);
assert!(flatten(&scene, Vec3::ZERO, &CancelToken::default()).is_ok());
let open = seam_tetra(Vec3::new(1.01e-6, 0.0, 0.0));
assert!(flatten(&open, Vec3::ZERO, &CancelToken::default()).is_err());
}
#[test]
fn spatial_weld_is_deterministic_without_transitive_or_diagonal_expansion() {
for _ in 0..8 {
let mut weld = MediumWeld::new(8).unwrap();
let a = weld.key(Vec3::ZERO).unwrap();
assert_eq!(a, weld.key(Vec3::new(0.75e-6, 0.0, 0.0)).unwrap());
assert_ne!(a, weld.key(Vec3::new(1.5e-6, 0.0, 0.0)).unwrap());
assert_ne!(a, weld.key(Vec3::new(0.9e-6, 0.9e-6, 0.9e-6)).unwrap());
assert!(weld.key(Vec3::new(f64::NAN, 0.0, 0.0)).is_err());
}
}
#[test]
fn spatial_weld_preserves_winding_and_connected_component_checks() {
let mut inward = seam_tetra(Vec3::ZERO);
for tri in &mut Arc::make_mut(&mut inward.meshes[0].mesh).triangles {
tri.swap(1, 2);
}
assert!(flatten(&inward, Vec3::ZERO, &CancelToken::default()).is_err());
let mut disconnected = seam_tetra(Vec3::ZERO);
let mesh = Arc::make_mut(&mut disconnected.meshes[0].mesh);
let second = mesh.clone();
mesh.positions.extend(second.positions.iter().map(|p| *p + Vec3::new(4.0, 0.0, 0.0)));
mesh.normals.extend(&second.normals);
mesh.triangles.extend(second.triangles.iter().map(|t| t.map(|i| i + 5)));
mesh.tri_face.extend(&second.tri_face);
let error = flatten(&disconnected, Vec3::ZERO, &CancelToken::default()).err().unwrap();
assert!(error.to_string().contains("one connected shell"), "{error}");
}
#[test]
fn weld_scratch_only_counts_the_largest_closed_medium() {
let glass = seam_tetra(Vec3::ZERO);
let triangle_bytes = 4 * std::mem::size_of::<Triangle>() as u64;
let mut opaque = glass.clone();
opaque.meshes[0].transmission = None;
assert_eq!(medium_preparation_bytes(&opaque), triangle_bytes);
assert!(medium_preparation_bytes(&opaque) <= 4 * 400);
opaque.meshes[0].transmission = Some(Transmission::new(Vec3::ZERO, Some(1.0)).unwrap());
assert_eq!(medium_preparation_bytes(&opaque), triangle_bytes);
let mut two = glass.clone();
two.meshes.push(glass.meshes[0].clone());
assert_eq!(medium_preparation_bytes(&two), medium_preparation_bytes(&glass) + triangle_bytes);
}
+105
View File
@@ -0,0 +1,105 @@
// Bounded straight connection, not refracted caustic transport. Host preparation
// certifies disjoint closed media; malformed crossing order is still blocked.
struct ShadowResult { transmittance: vec3<f32>, branch_pdf: f32, crossings: u32, solid: bool };
fn blocked_shadow() -> ShadowResult {
return ShadowResult(vec3<f32>(0.0),0.0,0u,false);
}
fn shadow_transmittance(origin: vec3<f32>, direction: vec3<f32>, limit: f32) -> ShadowResult {
var result = ShadowResult(vec3<f32>(1.0),1.0,0u,false);
var o = origin;
var o_low = vec3<f32>(0.0);
var ray_excluded = 0xffffffffu;
var ray_minimum = u.lighting.x;
var medium_id = 0.0;
var medium_ior = 1.0;
var sigma = vec3<f32>(0.0);
var entry = origin;
var entry_low = vec3<f32>(0.0);
// Up to 64 interfaces. If traversal cannot establish visibility, block it.
for (var step=0u; step<=64u; step+=1u) {
let traversed = dot(precise_point_delta(o,o_low,origin,vec3<f32>(0.0)),direction);
let remaining = limit-traversed;
// Outside media nothing beyond the endpoint matters. Inside, the
// endpoint branch below must still see the farther boundary.
let hit = trace_precise_within(o,o_low,direction,false,ray_excluded,ray_minimum,medium_id!=0.0,select(FAR,remaining,medium_id==0.0),remaining);
if hit.triangle == 0xffffffffu {
if medium_id!=0.0 { return blocked_shadow(); }
return result;
}
if hit.distance>=remaining {
// A finite emitter may be inside a certified medium. Attenuate only
// to that emitter, never to the body's farther exit surface.
if medium_id!=0.0 { result.transmittance*=exp(-sigma*length(ray_segment_precise(o,o_low,entry,entry_low,direction,remaining)))/(medium_ior*medium_ior); }
return result;
}
if step==64u { return blocked_shadow(); }
let t = triangle(hit.triangle);
if t.base.w<=0.0 || t.coat.w<=0.0 { return blocked_shadow(); }
var position=surface_point(t,hit.bary);
var position_low=vec3<f32>(0.0);
if t.parameters.w<0.0 {
let point=surface_point_precise(t,hit.bary);
position=point.hi; position_low=point.lo;
}
let outward=unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
let entering=dot(outward,direction)<0.0;
let geometric=select(-outward,outward,entering);
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,-direction)<=0.0 { n=geometric; }
if t.parameters.w>=0.0 {
if medium_id!=0.0 { return blocked_shadow(); }
// Match the primary path's shading-normal fallback before the sheet sum.
var f=dielectric(clamp(dot(n,-direction),0.0,1.0),1.0,t.parameters.z);
let refracted=refract(direction,n,1.0/t.parameters.z);
if (f.z>=0.0 && dot(refracted,geometric)>=0.0) || dot(reflect(direction,n),geometric)<=0.0 {
n=geometric;
f=dielectric(clamp(dot(n,-direction),0.0,1.0),1.0,t.parameters.z);
}
let weights=sheet(t,f);
let r=dot(weights.reflection,vec3<f32>(1.0/3.0));
let tr=dot(weights.transmission,vec3<f32>(1.0/3.0));
if r+tr<=0.0 || tr<=0.0 { return blocked_shadow(); }
result.transmittance*=weights.transmission;
result.branch_pdf*=tr/(r+tr);
} else {
result.solid=true;
// Reciprocal external-angle Fresnel at each surface. Applying an
// inside critical angle to this unrefracted connection would invent TIR.
let f=dielectric(clamp(abs(dot(outward,direction)),0.0,1.0),1.0,t.parameters.z);
result.transmittance*=1.0-(f.x+f.y)*0.5;
if entering {
if medium_id!=0.0 { return blocked_shadow(); }
medium_id=t.optics.w; medium_ior=t.parameters.z; entry=position; entry_low=position_low; sigma=t.optics.xyz;
} else {
// The first crossing from the shading point exits a closed body:
// the point touches or lies within it (see the path loop).
if medium_id==0.0 && step==0u {
medium_id=t.optics.w; medium_ior=t.parameters.z; sigma=t.optics.xyz; entry=origin; entry_low=vec3<f32>(0.0);
}
if medium_id==0.0 || medium_id!=t.optics.w { return blocked_shadow(); }
result.transmittance*=exp(-sigma*length(precise_point_delta(position,position_low,entry,entry_low)));
medium_id=0.0;
}
}
result.crossings+=1u;
if max(max(result.transmittance.x,result.transmittance.y),result.transmittance.z)<=0.0 { return blocked_shadow(); }
// Stay on the same line so the finite emitter endpoint remains meaningful.
// Absorption uses unshifted entry/exit endpoints, not this numerical offset.
o=position+direction*(u.lighting.x*2.0); o_low=vec3<f32>(0.0);
ray_excluded=0xffffffffu; ray_minimum=u.lighting.x;
if t.parameters.w<0.0 {
// Preserve arbitrarily short real corner segments, using the same
// producing-plane exclusion as closed primary-path continuation.
o=position; o_low=position_low; ray_excluded=hit.triangle; ray_minimum=0.0;
}
}
return blocked_shadow();
}
@compute @workgroup_size(1)
fn shadow_reference() {
let shadow=shadow_transmittance(u.eye.xyz,u.back.xyz,u.back.w);
accumulation[0]=vec4<f32>(shadow.transmittance,1.0);
accumulation[1]=vec4<f32>(shadow.branch_pdf,f32(shadow.crossings),select(0.0,1.0,shadow.solid),1.0);
}
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
//! One read-only storage buffer: body lookup, planar records and linear RGBA pixels.
use solidcraft_geom::Vec3;
use solidcraft_render::job::{
CancelToken, ErrorCode, JobError, Result,
textures::{RuntimeTextures, TextureMap},
};
fn vector(a: [f64; 3]) -> Vec3 {
Vec3::new(a[0], a[1], a[2])
}
fn write_word(bytes: &mut [u8], word: usize, value: u32) -> Result<()> {
bytes
.get_mut(word * 4..word * 4 + 4)
.ok_or_else(|| JobError::new(ErrorCode::Internal, "texture record outside allocation"))?
.copy_from_slice(&value.to_le_bytes());
Ok(())
}
fn pixels(bytes: &mut Vec<u8>, map: Option<&TextureMap>, cancel: &CancelToken) -> Result<(u32, [u32; 2])> {
let Some(map) = map else {
return Ok((0, [0; 2]));
};
cancel.check()?;
let rgba = map.decode(cancel)?;
let offset = bytes.len() / 4;
for (index, value) in rgba.into_iter().enumerate() {
if index % 4096 == 0 {
cancel.check()?;
}
bytes.extend_from_slice(&value.to_le_bytes());
}
Ok((offset as u32, map.dimensions()))
}
pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, origin: Vec3, cancel: &CancelToken) -> Result<Vec<u8>> {
let Some(resources) = resources else {
return Ok(vec![0; 16]);
};
let header = 4 + body_count + 1;
let record_words = resources.bindings().len() * 16;
let mut bytes = super::scene::reserved((header + record_words) * 4 + resources.summary().linear_pixel_bytes as usize)?;
bytes.resize((header + record_words) * 4, 0);
write_word(&mut bytes, 0, (body_count + 1) as u32)?;
for (index, binding) in resources.bindings().iter().enumerate() {
cancel.check()?;
let record = header + index * 16;
write_word(&mut bytes, 4 + binding.body_index + 1, record as u32)?;
let delta = vector(binding.frame.origin) - origin;
if [delta.x, delta.y, delta.z].iter().any(|v| v.abs() > 1e7) {
return Err(JobError::new(ErrorCode::InvalidScene, "texture frame must be within 1e7 mm of camera target"));
}
let u = vector(binding.frame.u);
let v = vector(binding.frame.v);
for (offset, p) in [(0, delta), (4, u / u.len2()), (8, v / v.len2())] {
for (k, c) in [p.x, p.y, p.z].into_iter().enumerate() {
write_word(&mut bytes, record + offset + k, (c as f32).to_bits())?;
}
}
let (base, base_size) = pixels(&mut bytes, binding.base_color.as_ref(), cancel)?;
let (normal, normal_size) = pixels(&mut bytes, binding.normal.as_ref(), cancel)?;
for (offset, value) in [
(3, base),
(7, base_size[0]),
(11, base_size[1]),
(12, normal),
(13, normal_size[0]),
(14, normal_size[1]),
(15, (binding.normal_strength as f32).to_bits()),
] {
write_word(&mut bytes, record + offset, value)?;
}
}
cancel.check()?;
Ok(bytes)
}
+58
View File
@@ -0,0 +1,58 @@
@group(0) @binding(5) var<storage, read> texture_words: array<u32>;
fn texture_vector(word:u32)->vec3<f32> {
return bitcast<vec3<f32>>(vec3<u32>(texture_words[word],texture_words[word+1u],texture_words[word+2u]));
}
fn texture_record(t:Triangle)->u32 {
let body=u32(t.optics.w);
if body>=texture_words[0] {return 0u;}
return texture_words[4u+body];
}
fn texture_pixel(offset:u32,width:u32,x:u32,y:u32)->vec3<f32> {
return texture_vector(offset+4u*(y*width+x));
}
fn texture_sample(offset:u32,size:vec2<u32>,uv:vec2<f32>)->vec3<f32> {
let position=fract(uv)*vec2<f32>(size)-vec2<f32>(0.5);
let lower=vec2<i32>(floor(position));
let first=vec2<u32>((lower+vec2<i32>(size))%vec2<i32>(size));
let second=(first+vec2<u32>(1u))%size;
let fraction=fract(position);
let a=texture_pixel(offset,size.x,first.x,first.y);
let b=texture_pixel(offset,size.x,second.x,first.y);
let c=texture_pixel(offset,size.x,first.x,second.y);
let d=texture_pixel(offset,size.x,second.x,second.y);
return mix(mix(a,b,fraction.x),mix(c,d,fraction.x),fraction.y);
}
fn texture_uv(record:u32,position:vec3<f32>)->vec2<f32> {
let delta=position-texture_vector(record);
return vec2<f32>(dot(delta,texture_vector(record+4u)),dot(delta,texture_vector(record+8u)));
}
fn texture_surface(t:Triangle,position:vec3<f32>)->Triangle {
let record=texture_record(t);var result=t;
if record==0u || texture_words[record+3u]==0u {return result;}
let rgb=texture_sample(texture_words[record+3u],vec2<u32>(texture_words[record+7u],texture_words[record+11u]),texture_uv(record,position));
// Texture modulates linear reflectance, never emission or scalar alpha.
result.base=vec4<f32>(t.base.xyz*rgb,t.base.w);
return result;
}
fn texture_normal(t:Triangle,position:vec3<f32>,n:vec3<f32>,geometric:vec3<f32>)->vec3<f32> {
let record=texture_record(t);
if record==0u || texture_words[record+12u]==0u {return n;}
let rgb=texture_sample(texture_words[record+12u],vec2<u32>(texture_words[record+13u],texture_words[record+14u]),texture_uv(record,position));
let u=unit(texture_vector(record+4u));
let tangent=u-n*dot(n,u);
if dot(tangent,tangent)<1e-12 {return n;}
let tangent_unit=unit(tangent);
let handedness=select(1.0,-1.0,dot(n,cross(texture_vector(record+4u),texture_vector(record+8u)))<0.0);
let strength=bitcast<f32>(texture_words[record+15u]);
let local=vec3<f32>((2.0*rgb.xy-vec2<f32>(1.0))*strength,2.0*rgb.z-1.0);
if dot(local,local)<1e-12 || local.z<=0.0 {return n;}
let mapped=unit(tangent_unit*local.x+cross(n,tangent_unit)*(handedness*local.y)+n*local.z);
return select(mapped,n,dot(mapped,geometric)<=0.0);
}
@compute @workgroup_size(1)
fn texture_reference() {
let t=triangle(0u);
let mapped=texture_surface(t,u.eye.xyz);
accumulation[0]=vec4<f32>(mapped.base.xyz,1.0);
accumulation[1]=vec4<f32>(texture_normal(t,u.eye.xyz,u.up.xyz,u.back.xyz)+vec3<f32>(2.0),1.0);
}
+6 -4
View File
@@ -34,9 +34,11 @@ fn vs_pbr(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) bas
return o;
}
fn pbr_fresnel(f0: vec3<f32>, cosine: f32) -> vec3<f32> {
fn pbr_fresnel(f0: vec3<f32>, cosine: f32, metallic: f32, ior: f32) -> vec3<f32> {
let t = 1.0 - cosine;
return f0 + (vec3<f32>(1.0) - f0) * (t * t * t * t * t);
// Match the shared surface: an index-matched dielectric has no interface.
let grazing = select(1.0, metallic, ior == 1.0);
return f0 + (vec3<f32>(grazing) - f0) * (t * t * t * t * t);
}
fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
@@ -54,9 +56,9 @@ fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>,
let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl));
let ratio = (parameters.z - 1.0) / (parameters.z + 1.0);
let f0 = mix(vec3<f32>(ratio * ratio), base, parameters.x);
let specular = pbr_fresnel(f0, vh) * (distribution * 0.25 * gv * gl);
let specular = pbr_fresnel(f0, vh, parameters.x, parameters.z) * (distribution * 0.25 * gv * gl);
let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793);
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl)) * diffuse;
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv, parameters.x, parameters.z)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl, parameters.x, parameters.z)) * diffuse;
}
@fragment
+24 -2
View File
@@ -91,7 +91,7 @@ impl Policy {
}
}
fn admits(self, tier: Tier) -> bool {
pub(crate) fn admits(self, tier: Tier) -> bool {
match self {
Policy::Auto => true,
Policy::Hardware => tier == Tier::Hardware,
@@ -139,7 +139,7 @@ pub fn native_backends() -> wgpu::Backends {
}
}
fn tier_of(t: wgpu::DeviceType) -> Tier {
pub(crate) fn tier_of(t: wgpu::DeviceType) -> Tier {
match t {
wgpu::DeviceType::Cpu => Tier::Software,
wgpu::DeviceType::DiscreteGpu | wgpu::DeviceType::IntegratedGpu | wgpu::DeviceType::VirtualGpu | wgpu::DeviceType::Other => Tier::Hardware,
@@ -201,6 +201,28 @@ pub fn survey(policy: Policy) -> Survey {
Survey { backends: format!("{:?}", native_backends()), adapters: found, chosen, lands_on }
}
/// The Vulkan adapter `policy` picks among those `keep` accepts, with its report; `None` where
/// the native backends (or `WGPU_BACKEND`) leave out Vulkan, as on macOS. The GPU path tracer
/// runs on Vulkan only (#3217): D3D12 was never faster in our measurements and needs minutes to
/// compile its shader at every start, where Vulkan keeps compiled pipelines between runs.
#[cfg(not(target_arch = "wasm32"))]
pub fn pick_vulkan(policy: Policy, keep: impl Fn(&wgpu::AdapterInfo) -> bool) -> Option<(wgpu::Adapter, AdapterReport)> {
if !native_backends().contains(wgpu::Backends::VULKAN) {
return None;
}
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
desc.backends = wgpu::Backends::VULKAN;
let mut all = pollster::block_on(wgpu::Instance::new(desc).enumerate_adapters(wgpu::Backends::VULKAN));
all.sort_by_key(|a| std::cmp::Reverse(rank(a.get_info().device_type)));
all.into_iter()
.filter(|a| keep(&a.get_info()))
.map(|a| {
let r = report(&a.get_info());
(a, r)
})
.find(|(_, r)| policy.admits(r.tier))
}
/// The adapter `policy` picks, with its report; `None` when no adapter qualifies.
#[cfg(not(target_arch = "wasm32"))]
pub fn pick(policy: Policy) -> Option<(wgpu::Adapter, AdapterReport)> {
+96 -22
View File
@@ -1,11 +1,12 @@
//! Portable scalar libraries; no renderer, network, external resources or design links.
//! Portable material libraries; no network, external resources or design links.
use crate::{IoError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use solidcraft_doc::{Document, UserMaterial, appearance::Look, valid_density};
use std::collections::BTreeMap;
pub const FORMAT: &str = "solidcraft-library/1";
pub const FORMAT: &str = "solidcraft-library/2";
pub const V1_FORMAT: &str = "solidcraft-library/1";
pub const MAX_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_ENTRIES: usize = 1024;
/// Bound source inspection even when many assignments reuse the same definition.
@@ -51,47 +52,57 @@ impl Provenance {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScalarLook {
pub struct LibraryLook {
pub name: String,
pub color: [u8; 3],
pub opacity: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pbr: Option<solidcraft_doc::pbr_appearance::PbrParameters>,
}
impl ScalarLook {
impl LibraryLook {
pub fn from_look(look: &Look) -> Result<Self> {
if look.pbr.is_some() {
return Err(invalid("scalar catalog v1 cannot preserve PBR parameters; export refused"));
}
Ok(Self { name: look.name.clone(), color: look.color, opacity: look.opacity })
let out = Self { name: look.name.clone(), color: look.color, opacity: look.opacity, pbr: look.pbr };
out.validate()?;
Ok(out)
}
pub fn to_look(&self) -> Look {
Look { name: self.name.clone(), color: self.color, opacity: self.opacity, pbr: None }
Look { name: self.name.clone(), color: self.color, opacity: self.opacity, pbr: self.pbr }
}
fn validate(&self) -> Result<()> {
if !text(&self.name, 128) || !self.opacity.is_finite() || !(0.0..=1.0).contains(&self.opacity) {
return Err(invalid("library appearance needs a 1–128 byte name and finite opacity 0–1"));
}
self.to_look().opaque_surface().map_err(|e| invalid(e.to_string()))?;
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScalarMaterial {
pub struct LibraryMaterial {
pub name: String,
pub density_g_cm3: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub appearance: Option<ScalarLook>,
pub appearance: Option<LibraryLook>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub physical_card: Option<solidcraft_doc::physical_material::ValidatedCard>,
}
impl ScalarMaterial {
impl LibraryMaterial {
pub fn from_material(material: &UserMaterial) -> Result<Self> {
Ok(Self {
name: material.name.clone(),
density_g_cm3: material.density,
appearance: material.appearance.as_ref().map(ScalarLook::from_look).transpose()?,
appearance: material.appearance.as_ref().map(LibraryLook::from_look).transpose()?,
physical_card: material.physical_card.clone(),
})
}
pub fn to_material(&self, name: String) -> UserMaterial {
UserMaterial { name, density: self.density_g_cm3, appearance: self.appearance.as_ref().map(ScalarLook::to_look) }
UserMaterial {
name,
density: self.density_g_cm3,
appearance: self.appearance.as_ref().map(LibraryLook::to_look),
physical_card: self.physical_card.clone(),
}
}
fn validate(&self) -> Result<()> {
if !text(&self.name, 128) || !valid_density(self.density_g_cm3) {
@@ -107,10 +118,16 @@ impl ScalarMaterial {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case", deny_unknown_fields)]
pub enum Definition {
Appearance(ScalarLook),
Material(ScalarMaterial),
Appearance(LibraryLook),
Material(LibraryMaterial),
}
impl Definition {
fn requires_v2(&self) -> bool {
match self {
Self::Appearance(look) => look.pbr.is_some(),
Self::Material(material) => material.physical_card.is_some() || material.appearance.as_ref().is_some_and(|l| l.pbr.is_some()),
}
}
pub fn name(&self) -> &str {
match self {
Self::Appearance(l) => &l.name,
@@ -140,7 +157,8 @@ pub struct Entry {
pub definition: Definition,
}
impl Entry {
/// Revision is SHA-256 of compact serde JSON `[provenance, definition]` in this v1 contract.
/// SHA-256 of compact serde JSON `[provenance, definition]`. Absent v2 fields
/// are omitted, preserving the exact v1 scalar writer contract.
pub fn content_revision(&self) -> Result<String> {
self.provenance.validate()?;
self.definition.validate()?;
@@ -207,11 +225,15 @@ fn bounded_entries<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::re
}
impl Catalog {
pub fn validate(&self) -> Result<()> {
if self.format != FORMAT || !identifier(&self.id) || !text(&self.name, 128) || self.entries.len() > MAX_ENTRIES {
return Err(invalid("unsupported or invalid scalar library (v1, 1024 entries, 128 byte name)"));
if !matches!(self.format.as_str(), FORMAT | V1_FORMAT) || !identifier(&self.id) || !text(&self.name, 128) || self.entries.len() > MAX_ENTRIES
{
return Err(invalid("unsupported or invalid library (v1/v2, 1024 entries, 128 byte name)"));
}
let mut ids = std::collections::BTreeSet::new();
for entry in &self.entries {
if self.format == V1_FORMAT && entry.definition.requires_v2() {
return Err(invalid("catalog v1 cannot preserve PBR or engineering cards; export refused"));
}
entry.validate(&self.id)?;
if !ids.insert(&entry.id) {
return Err(invalid("duplicate library entry identity"));
@@ -223,18 +245,53 @@ impl Catalog {
if bytes.len() > MAX_BYTES {
return Err(invalid("scalar library exceeds 4 MiB"));
}
// Keep the v1 wire schema strict, including newly recognized null fields.
// This bounded raw preflight is not arbitrary JSON canonicalization.
let raw: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| invalid(format!("invalid library: {e}")))?;
if raw.get("format").and_then(serde_json::Value::as_str) == Some(V1_FORMAT)
&& let Some(entries) = raw.get("entries").and_then(serde_json::Value::as_array)
{
if entries.len() > MAX_ENTRIES {
return Err(invalid("library exceeds 1024 entries"));
}
for entry in entries {
if let Some(value) = entry.get("definition").and_then(|d| d.get("value")) {
let has = |v: &serde_json::Value, key: &str| v.as_object().is_some_and(|o| o.contains_key(key));
if has(value, "pbr") || has(value, "physical_card") || value.get("appearance").is_some_and(|a| has(a, "pbr")) {
return Err(invalid("v1 definitions cannot contain v2 fields"));
}
}
}
}
drop(raw);
let catalog: Self = serde_json::from_slice(bytes).map_err(|e| invalid(format!("invalid scalar library: {e}")))?;
catalog.validate()?;
Ok(catalog)
}
pub fn encode(&self) -> Result<Vec<u8>> {
self.validate()?;
let mut budget = 4096usize;
for entry in &self.entries {
budget = budget.saturating_add(entry_encoded_bound(entry)?);
if budget > MAX_BYTES {
return Err(invalid("library exceeds 4 MiB encoded admission budget"));
}
}
let bytes = serde_json::to_vec_pretty(self).map_err(|e| invalid(e.to_string()))?;
if bytes.len() > MAX_BYTES {
return Err(invalid("scalar library exceeds 4 MiB"));
}
Ok(bytes)
}
/// Change only the envelope version; reject any lossy downgrade.
pub fn in_format(mut self, format: &str) -> Result<Self> {
if !matches!(format, FORMAT | V1_FORMAT) {
return Err(invalid("unsupported library format"));
}
self.format = format.into();
self.validate()?;
Ok(self)
}
pub fn read(path: &str) -> Result<Self> {
// Check the VFS size first as well: its browser read clones the stored bytes.
if crate::vfs::len(path).map_err(|e| invalid(e.to_string()))? > MAX_BYTES as u64 {
@@ -258,6 +315,7 @@ impl Catalog {
return Err(invalid("library export exceeds 100000 source references"));
}
let mut entries = BTreeMap::new();
let mut budget = 4096usize;
let mut insert = |definition| -> Result<()> {
let entry = Entry::new(id, definition, provenance.clone())?;
if let Some(previous) = entries.get(&entry.id) {
@@ -268,17 +326,21 @@ impl Catalog {
if entries.len() >= MAX_ENTRIES {
return Err(invalid("scalar library exceeds 1024 unique entries"));
}
budget = budget.saturating_add(entry_encoded_bound(&entry)?);
if budget > MAX_BYTES {
return Err(invalid("library exceeds 4 MiB encoded admission budget"));
}
entries.insert(entry.id.clone(), entry);
}
Ok(())
};
for look in doc.appearances.bodies.values().chain(doc.appearances.components.values()).chain(doc.appearances.faces.iter().map(|f| &f.look)) {
insert(Definition::Appearance(ScalarLook::from_look(look)?))?;
insert(Definition::Appearance(LibraryLook::from_look(look)?))?;
}
for material in &doc.user_materials {
insert(Definition::Material(ScalarMaterial::from_material(material)?))?;
insert(Definition::Material(LibraryMaterial::from_material(material)?))?;
if let Some(look) = &material.appearance {
insert(Definition::Appearance(ScalarLook::from_look(look)?))?;
insert(Definition::Appearance(LibraryLook::from_look(look)?))?;
}
}
let out = Self { format: FORMAT.into(), id: id.into(), name: name.into(), entries: entries.into_values().collect() };
@@ -287,6 +349,18 @@ impl Catalog {
}
}
/// Bound retained unique definitions and the final pretty writer before a full
/// catalog allocation. Each admitted entry is bounded independently; reserve
/// envelope text, four spaces per nested line, separators and a final newline.
fn entry_encoded_bound(entry: &Entry) -> Result<usize> {
let bytes = serde_json::to_vec_pretty(entry).map_err(|e| invalid(e.to_string()))?;
let lines = bytes.iter().filter(|b| **b == b'\n').count().saturating_add(1);
Ok(bytes.len().saturating_add(lines.saturating_mul(4)).saturating_add(2))
}
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "catalog_v2_tests.rs"]
mod v2_tests;
+18 -9
View File
@@ -4,7 +4,7 @@ use serde_json::json;
fn catalog() -> Catalog {
let entry = Entry::new(
"user.test",
Definition::Appearance(ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }),
Definition::Appearance(LibraryLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8, pbr: None }),
Provenance::default(),
)
.unwrap();
@@ -34,7 +34,7 @@ fn strict_schema_rejects_unknown_kinds_fields_versions_and_bad_identity() {
for changed in [
{
let mut v = value.clone();
v["format"] = json!("solidcraft-library/2");
v["format"] = json!("solidcraft-library/99");
v
},
{
@@ -96,14 +96,17 @@ fn scalar_validation_refuses_nonfinite_density_opacity_counts_strings_and_oversi
assert!(
Entry::new(
"user.test",
Definition::Material(ScalarMaterial { name: "Timber".into(), density_g_cm3: density, appearance: None }),
Definition::Material(LibraryMaterial { name: "Timber".into(), density_g_cm3: density, appearance: None, physical_card: None }),
Provenance::default()
)
.is_err()
);
}
for name in ["".into(), "x".repeat(129), "bad\nname".into(), " leading".into()] {
assert!(Entry::new("user.test", Definition::Appearance(ScalarLook { name, color: [1, 2, 3], opacity: 1.0 }), Provenance::default()).is_err());
assert!(
Entry::new("user.test", Definition::Appearance(LibraryLook { name, color: [1, 2, 3], opacity: 1.0, pbr: None }), Provenance::default())
.is_err()
);
}
assert!(Catalog::decode(&vec![b' '; MAX_BYTES + 1]).is_err());
let source = Provenance { source_url: Some("x".repeat(2049)), ..Default::default() };
@@ -112,10 +115,10 @@ fn scalar_validation_refuses_nonfinite_density_opacity_counts_strings_and_oversi
#[test]
fn design_export_deduplicates_exact_looks_and_keeps_physical_properties_distinct() {
let mut doc = Document::default();
let look = ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }.to_look();
let look = LibraryLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8, pbr: None }.to_look();
doc.appearances.bodies.insert("Body1".into(), look.clone());
doc.appearances.bodies.insert("Body2".into(), look.clone());
doc.user_materials.push(UserMaterial { name: "Timber density".into(), density: 0.7, appearance: Some(look) });
doc.user_materials.push(UserMaterial { name: "Timber density".into(), density: 0.7, appearance: Some(look), physical_card: None });
let library = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap();
assert_eq!(library.entries.len(), 2);
assert!(library.entries.iter().any(|e| matches!(&e.definition,Definition::Material(m) if m.density_g_cm3==0.7)));
@@ -127,7 +130,7 @@ fn design_export_deduplicates_exact_looks_and_keeps_physical_properties_distinct
#[test]
fn scalar_export_refuses_pbr_in_every_design_owned_appearance_location() {
use solidcraft_doc::{appearance::FaceLook, pbr_appearance::PbrParameters};
let mut look = ScalarLook { name: "Original PBR specimen".into(), color: [180, 120, 70], opacity: 1.0 }.to_look();
let mut look = LibraryLook { name: "Original PBR specimen".into(), color: [180, 120, 70], opacity: 1.0, pbr: None }.to_look();
look.pbr = Some(PbrParameters::new(1.0, 0.3, 1.5).unwrap());
for location in 0..4 {
let mut doc = Document::default();
@@ -146,10 +149,16 @@ fn scalar_export_refuses_pbr_in_every_design_owned_appearance_location() {
strict_name: false,
certificate: None,
}),
_ => doc.user_materials.push(UserMaterial { name: "Original material".into(), density: 1.2, appearance: Some(look.clone()) }),
_ => doc.user_materials.push(UserMaterial {
name: "Original material".into(),
density: 1.2,
appearance: Some(look.clone()),
physical_card: None,
}),
}
let before = doc.clone();
let error = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap_err();
let rich = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap();
let error = rich.in_format(V1_FORMAT).unwrap_err();
assert!(error.to_string().contains("cannot preserve PBR"));
assert_eq!(doc, before);
}
+135
View File
@@ -0,0 +1,135 @@
use super::*;
use serde_json::{Value, json};
use solidcraft_doc::{pbr_appearance::PbrParameters, physical_material::ValidatedCard};
// Exact bytes and revisions captured from the pre-v2 writer at 75e66eea.
const V1: &str = r#"{
"format": "solidcraft-library/1",
"id": "user.test",
"name": "Original v1 fixture",
"entries": [
{
"id": "user.test/appearance-c17adc43c1eb8c7d",
"revision": "c17adc43c1eb8c7d6badd7e7821150509bf513eb29c75a90c4027f46d9b49437",
"provenance": {
"license": "LicenseRef-User-Provided"
},
"definition": {
"kind": "appearance",
"value": {
"name": "Original scalar study",
"color": [
180,
120,
70
],
"opacity": 0.8
}
}
}
]
}"#;
fn card() -> ValidatedCard {
ValidatedCard::from_value(&json!({"schema_version":1,"designation":"Original synthetic card","family":"other",
"sources":[{"id":"fixture","title":"Original invented constants","kind":"illustrative"}],
"properties":[{"source_id":"fixture","data":{"kind":"elasticity","model":{
"symmetry":"isotropic","young_modulus_pa":12.25e9,"poisson_ratio":0.25}}}]}))
.unwrap()
}
fn rich() -> Catalog {
let mut doc = Document::default();
let mut look = Look::custom([180, 120, 70], 1.0);
look.name = "Original opaque study".into();
look.pbr = Some(PbrParameters::new(1.0, 0.3, 1.5).unwrap());
doc.appearances.bodies.insert("Body1".into(), look.clone());
doc.user_materials.push(UserMaterial { name: "Original candidate".into(), density: 0.8, appearance: Some(look), physical_card: Some(card()) });
Catalog::from_document(&doc, "user.test", "Original v2 fixture", Default::default()).unwrap()
}
#[test]
fn pinned_v1_bytes_revisions_and_scalar_material_canonical_fields_are_preserved() {
let old = Catalog::decode(V1.as_bytes()).unwrap();
assert_eq!(old.encode().unwrap(), V1.as_bytes());
let upgraded = old.clone().in_format(FORMAT).unwrap();
assert_eq!(upgraded.entries, old.entries);
assert_eq!(upgraded.encode().unwrap(), V1.replacen(V1_FORMAT, FORMAT, 1).as_bytes());
let material = LibraryMaterial {
name: "Original scalar material".into(),
density_g_cm3: 0.7,
appearance: Some(LibraryLook { name: "Original scalar material".into(), color: [180, 120, 70], opacity: 1.0, pbr: None }),
physical_card: None,
};
let provenance = Provenance {
license: "MIT OR Apache-2.0".into(),
author: Some("SolidCraft contributors".into()),
source_url: Some("https://example.invalid/original".into()),
};
let entry = Entry::new("user.test", Definition::Material(material), provenance).unwrap();
assert_eq!(entry.revision, "3dc003a875463baef587b85b9883f227cb546178317271c60078989bdb86c245");
assert_eq!(entry.id, "user.test/material-3dc003a875463bae");
}
#[test]
fn v2_roundtrip_keeps_optics_cards_and_revisions_cover_richer_data() {
let original = rich();
let copy = Catalog::decode(&original.encode().unwrap()).unwrap();
assert_eq!(copy, original);
assert!(copy.clone().in_format(V1_FORMAT).is_err());
let mut changed = copy.clone();
for entry in &mut changed.entries {
match &mut entry.definition {
Definition::Appearance(l) => l.pbr = Some(PbrParameters::new(0., 0.8, 1.4).unwrap()),
Definition::Material(m) => {
assert_eq!(m.to_material(m.name.clone()).physical_card, Some(card()));
let mut v = serde_json::to_value(card()).unwrap();
v["designation"] = json!("Changed synthetic designation");
m.physical_card = Some(ValidatedCard::from_value(&v).unwrap());
}
}
assert_ne!(entry.content_revision().unwrap(), entry.revision);
}
assert!(changed.validate().is_err());
}
#[test]
fn wire_versions_unknown_fields_invalid_optics_and_hostile_cards_refuse() {
let legacy: Value = serde_json::from_str(V1).unwrap();
for value in [Value::Null, json!({"metallic":0,"roughness":0.3,"dielectric_ior":1.5})] {
let mut bad = legacy.clone();
bad["entries"][0]["definition"]["value"]["pbr"] = value;
assert!(Catalog::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
}
let v = serde_json::to_value(rich()).unwrap();
let appearance = v["entries"].as_array().unwrap().iter().position(|e| e["definition"]["kind"] == "appearance").unwrap();
let material = v["entries"].as_array().unwrap().iter().position(|e| e["definition"]["kind"] == "material").unwrap();
for (path, value) in [("opacity", json!(0.5)), ("pbr", json!({"metallic":0,"roughness":0,"dielectric_ior":1.5})), ("unknown", json!(true))] {
let mut bad = v.clone();
bad["entries"][appearance]["definition"]["value"][path] = value;
assert!(Catalog::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
}
let mut bad = v.clone();
bad["entries"][material]["definition"]["value"]["physical_card"]["sources"] = json!(vec![json!({}); 33]);
assert!(Catalog::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
let mut downgraded = v.clone();
downgraded["format"] = json!(V1_FORMAT);
assert!(Catalog::decode(&serde_json::to_vec(&downgraded).unwrap()).is_err());
}
#[test]
fn rich_export_bounds_retained_definitions_before_whole_catalog_encoding() {
let mut v = serde_json::to_value(card()).unwrap();
v["properties"] = json!([]);
v["sources"] = json!(
(0..32)
.map(|i| json!({"id":format!("s{i}"),"title":"Original synthetic metadata","kind":"illustrative","reference":"x".repeat(2048)}))
.collect::<Vec<_>>()
);
let record = ValidatedCard::from_value(&v).unwrap();
let mut doc = Document::default();
for i in 0..64 {
doc.user_materials.push(UserMaterial { name: format!("Candidate {i}"), density: 0.8, appearance: None, physical_card: Some(record.clone()) });
}
assert!(Catalog::from_document(&doc, "user.test", "Original large fixture", Default::default()).unwrap_err().to_string().contains("4 MiB"));
}
+370
View File
@@ -0,0 +1,370 @@
//! Original anisotropic GGX reflection with separable Smith masking and Schlick Fresnel.
//!
//! Mathematics: Heitz, JCGT 7(4), 2018, equations 1 and 2,
//! <https://jcgt.org/published/0007/04/01/>. No external implementation adapted.
//! The explicit legacy numerical controls use the **NDF**:
//! `p(h) = D(h) * n.h`, then `p(l) = p(h) / (4 * v.h)`.
//! Rejected reflections are null events, never resampled or renormalized.
//! The separate `sample_visible_reflection` API uses a matched GGX VNDF and PDF.
//! Its original spherical-cap construction follows Dupuy and Benyoub (2023),
//! <https://arxiv.org/abs/2306.05044>; see docs/render-vndf-prototype.md.
//!
//! This numerical module does not install a renderer, persist a material or alter
//! engineering axes. The visual frame's normal may be a shading normal, but the
//! original geometric normal is required separately to reject below-surface rays.
//! Consumers retain geometric normals for ray spawning and optical boundaries.
//! No shading-normal energy correction, multiple scattering or transmission is modeled.
use std::f64::consts::{PI, TAU};
use solidcraft_geom::Vec3;
use crate::surface::{MIN_ROUGHNESS, OpaqueSurface, SurfaceError, unit_direction};
/// Admission or finite-arithmetic failure in the anisotropic numerical contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnisotropicError {
/// An existing opaque-surface parameter or direction was invalid.
Surface(SurfaceError),
/// A visual frame was not orthogonal, or opposed its geometric normal.
Frame,
/// A variate was not finite in the half-open interval [0, 1).
Variates,
/// A computed density, response or sample weight was not finite/nonnegative.
Arithmetic,
}
impl From<SurfaceError> for AnisotropicError {
fn from(value: SurfaceError) -> Self {
Self::Surface(value)
}
}
impl std::fmt::Display for AnisotropicError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Surface(e) => std::fmt::Display::fmt(e, f),
Self::Frame => f.write_str("visual frame must be orthogonal and face the geometric normal"),
Self::Variates => f.write_str("anisotropic variates must be finite in [0, 1)"),
Self::Arithmetic => f.write_str("anisotropic result must be finite and nonnegative"),
}
}
}
impl std::error::Error for AnisotropicError {}
type Result<T> = std::result::Result<T, AnisotropicError>;
/// Right-handed orthonormal visual axes; independent of engineering material axes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VisualFrame {
normal: Vec3,
tangent: Vec3,
bitangent: Vec3,
}
impl VisualFrame {
/// Normalize finite nonzero orthogonal inputs. An arbitrary oblique tangent is
/// refused rather than silently choosing a different brushed direction.
/// Roundoff within 1e-10 of perpendicular is removed by orthogonalization.
pub fn new(normal: Vec3, tangent: Vec3) -> Result<Self> {
let normal = unit_direction(normal)?;
let tangent = unit_direction(tangent)?;
let dot = normal.dot(tangent);
if dot.abs() > 1e-10 {
return Err(AnisotropicError::Frame);
}
let tangent = unit_direction(tangent - normal * dot)?;
let bitangent = unit_direction(normal.cross(tangent))?;
Ok(Self { normal, tangent, bitangent })
}
/// Shading normal, never a replacement for an original geometric normal.
pub fn normal(self) -> Vec3 {
self.normal
}
/// Visual X axis, associated with the first roughness.
pub fn tangent(self) -> Vec3 {
self.tangent
}
/// Visual Y axis, associated with the second roughness.
pub fn bitangent(self) -> Vec3 {
self.bitangent
}
fn local(self, direction: Vec3) -> Vec3 {
Vec3::new(self.tangent.dot(direction), self.bitangent.dot(direction), self.normal.dot(direction))
}
fn world(self, local: Vec3) -> Vec3 {
self.tangent * local.x + self.bitangent * local.y + self.normal * local.z
}
fn geometric(self, normal: Vec3) -> Result<Vec3> {
let normal = unit_direction(normal)?;
if normal.dot(self.normal) <= 0.0 {
return Err(AnisotropicError::Frame);
}
Ok(normal)
}
}
/// A bounded opaque response with two perceptual roughness axes in [0.045, 1].
/// Alpha is roughness squared, matching [`OpaqueSurface`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnisotropicSurface {
base: OpaqueSurface,
roughness_y: f64,
}
/// One reflected **specular-only** sample. Fields are in world coordinates.
/// Its producing method identifies the NDF or visible-normal proposal.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ReflectionSample {
/// Outgoing direction, pointing away from the surface.
pub direction: Vec3,
/// Unconditional density per steradian, without renormalizing null events.
pub pdf: f64,
/// `specular_brdf * shading_cosine / pdf`, in linear RGB.
/// Diffuse response is excluded; its sampling strategy belongs to the consumer.
pub weight: Vec3,
}
impl AnisotropicSurface {
/// Linear-sRGB reflectance, metal fraction and IOR use the opaque contract.
/// Perfect delta mirrors and out-of-range roughness are refused, not clamped.
pub fn new(base_color: Vec3, metallic: f64, roughness_x: f64, roughness_y: f64, dielectric_ior: f64) -> Result<Self> {
let base = OpaqueSurface::new(base_color, metallic, roughness_x, dielectric_ior)?;
if !roughness_y.is_finite() || !(MIN_ROUGHNESS..=1.0).contains(&roughness_y) {
return Err(SurfaceError::Roughness.into());
}
Ok(Self { base, roughness_y })
}
/// Admitted perceptual roughness along visual X and Y.
pub fn roughness(self) -> [f64; 2] {
[self.base.roughness(), self.roughness_y]
}
fn alpha(self) -> [f64; 2] {
[self.base.roughness().powi(2), self.roughness_y.powi(2)]
}
fn fresnel(self, cosine: f64) -> Vec3 {
self.base.specular_fresnel(cosine)
}
fn distribution_local(self, h: Vec3) -> f64 {
if h.z <= 0.0 {
return 0.0;
}
let [ax, ay] = self.alpha();
let q = (h.x / ax).powi(2) + (h.y / ay).powi(2) + h.z * h.z;
1.0 / (PI * ax * ay * q * q)
}
// Smith G1 / cosine, avoiding division by tiny cosines and tan(theta).
fn visibility(self, w: Vec3) -> f64 {
let [ax, ay] = self.alpha();
2.0 / (w.z + (w.z * w.z + (ax * w.x).powi(2) + (ay * w.y).powi(2)).sqrt())
}
/// GGX normal distribution D, per steradian of microfacet normal.
/// This is not a normalized probability density: [`Self::normal_pdf`] is.
pub fn distribution(self, frame: VisualFrame, half: Vec3) -> Result<f64> {
finite_scalar(self.distribution_local(frame.local(unit_direction(half)?)))
}
/// Projected NDF density `D(h) * n.h` on the upper shading hemisphere.
/// Its integral over that hemisphere is one.
pub fn normal_pdf(self, frame: VisualFrame, half: Vec3) -> Result<f64> {
let h = frame.local(unit_direction(half)?);
finite_scalar(self.distribution_local(h) * h.z.max(0.0))
}
/// Sample the projected NDF by stretching a radial GGX slope distribution.
/// The inverse radial CDF is `r = sqrt(u/(1-u))`; homogeneous coordinates
/// `(alpha_x*sqrt(u)*cos(phi), alpha_y*sqrt(u)*sin(phi), sqrt(1-u))`
/// avoid a large intermediate radius. No heap, rejection loop or RNG is used.
pub fn sample_normal(self, frame: VisualFrame, variates: [f64; 2]) -> Result<Vec3> {
let [u, azimuth] = variates;
if !variates.into_iter().all(|v| v.is_finite() && (0.0..1.0).contains(&v)) {
return Err(AnisotropicError::Variates);
}
let [ax, ay] = self.alpha();
let phi = TAU * azimuth;
let radial = u.sqrt();
let h = unit_direction(Vec3::new(ax * radial * phi.cos(), ay * radial * phi.sin(), (1.0 - u).sqrt()))?;
Ok(unit_direction(frame.world(h))?)
}
/// One view-conditioned visible-normal attempt. The legacy `sample_normal`
/// remains an explicitly projected-NDF utility, not this production proposal.
/// An upper-hemisphere view is required; no retries or probability clamps.
pub fn sample_visible_normal(self, frame: VisualFrame, view: Vec3, variates: [f64; 2]) -> Result<Option<Vec3>> {
if !variates.into_iter().all(|u| u.is_finite() && (0.0..1.0).contains(&u)) {
return Err(AnisotropicError::Variates);
}
let v = frame.local(unit_direction(view)?);
if v.z <= 0.0 {
return Ok(None);
}
let [ax, ay] = self.alpha();
let stretched = unit_direction(Vec3::new(ax * v.x, ay * v.y, v.z))?;
// The reflected unit-roughness hemisphere is uniform on z in [-v.z,1].
// Factor the radial square, avoiding a negative 1-z*z from cancellation.
let z = 1.0 - variates[0] * (1.0 + stretched.z);
let radius = ((1.0 - z) * (1.0 + z)).sqrt();
let phi = TAU * variates[1];
// Directly retain (v+c).z; adding cap.z to v.z loses this positive
// component near the lower cap boundary. Normalization is homogeneous,
// so apply the covector stretch before the single local normalization.
let height = (1.0 - variates[0]) * (1.0 + stretched.z);
let h = unit_direction(Vec3::new(ax * (stretched.x + radius * phi.cos()), ay * (stretched.y + radius * phi.sin()), height))?;
Ok(Some(unit_direction(frame.world(h))?))
}
/// Visible-normal density D(h)*G1(v)*max(v.h,0)/(n.v), per steradian.
/// This is distinct from the projected-NDF `normal_pdf` utility.
pub fn visible_normal_pdf(self, frame: VisualFrame, view: Vec3, half: Vec3) -> Result<f64> {
let v = frame.local(unit_direction(view)?);
let h = frame.local(unit_direction(half)?);
let vh = v.dot(h);
if v.z <= 0.0 || h.z <= 0.0 || vh <= 0.0 {
return Ok(0.0);
}
finite_scalar(self.distribution_local(h) * self.visibility(v) * vh)
}
/// Matched unconditional VNDF reflected-direction density; null attempts are
/// not renormalized. Reflection's Jacobian cancels the positive v.h factor.
pub fn visible_reflection_pdf(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<f64> {
let Some((v, _, h)) = self.directions(frame, geometric, view, light)? else { return Ok(0.0) };
finite_scalar(self.distribution_local(h) * self.visibility(v) * 0.25)
}
/// One VNDF specular attempt using the unchanged separable-Smith BSDF.
/// Weight f*cos/pdf reduces to F(v.h)*G1(l), without dividing by tiny n.v
/// or v.h. Geometric/shading rejection remains a zero event counted in R.
pub fn sample_visible_reflection(self, frame: VisualFrame, geometric: Vec3, view: Vec3, variates: [f64; 2]) -> Result<Option<ReflectionSample>> {
let geometric_unit = frame.geometric(geometric)?;
let view_unit = unit_direction(view)?;
let Some(half) = self.sample_visible_normal(frame, view, variates)? else { return Ok(None) };
if geometric_unit.dot(view_unit) <= 0.0 {
return Ok(None);
}
let vh = view_unit.dot(half);
if vh <= 0.0 {
return Ok(None);
}
let light = unit_direction(half * (2.0 * vh) - view_unit)?;
let Some((v, l, h)) = self.directions(frame, geometric, view, light)? else { return Ok(None) };
let pdf = finite_scalar(self.distribution_local(h) * self.visibility(v) * 0.25)?;
if pdf <= 0.0 {
return Err(AnisotropicError::Arithmetic);
}
let weight = finite_rgb(self.fresnel(v.dot(h)) * (l.z * self.visibility(l)))?;
Ok(Some(ReflectionSample { direction: light, pdf, weight }))
}
fn directions(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<Option<(Vec3, Vec3, Vec3)>> {
let geometric = frame.geometric(geometric)?;
let view = unit_direction(view)?;
let light = unit_direction(light)?;
let (v, l) = (frame.local(view), frame.local(light));
if v.z <= 0.0 || l.z <= 0.0 || geometric.dot(view) <= 0.0 || geometric.dot(light) <= 0.0 {
return Ok(None);
}
let h = unit_direction(view + light)?;
Ok(Some((v, l, frame.local(h))))
}
fn specular_local(self, v: Vec3, l: Vec3, h: Vec3) -> Result<Vec3> {
finite_rgb(self.fresnel(v.dot(h)) * (self.distribution_local(h) * 0.25 * self.visibility(v) * self.visibility(l)))
}
/// Specular component only, with independent geometric hemisphere rejection.
/// All input directions point away from the surface.
pub fn specular_brdf(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<Vec3> {
let Some((v, l, h)) = self.directions(frame, geometric, view, light)? else { return Ok(Vec3::ZERO) };
self.specular_local(v, l, h)
}
/// Full opaque BRDF, including the same symmetric attenuated Lambert term as
/// [`OpaqueSurface`]. No radiance, cosine, exposure or display transform included.
/// Equal roughness axes delegate to the existing isotropic numerical contract.
pub fn brdf(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<Vec3> {
let Some((v, l, h)) = self.directions(frame, geometric, view, light)? else { return Ok(Vec3::ZERO) };
if self.base.roughness() == self.roughness_y {
return Ok(self.base.brdf(frame.normal, view, light)?);
}
let specular = self.specular_local(v, l, h)?;
let fv = self.fresnel(v.z);
let fl = self.fresnel(l.z);
let diffuse = self.base.base_color() * ((1.0 - self.base.metallic()) / PI);
finite_rgb(
specular
+ Vec3::new(
(1.0 - fv.x) * (1.0 - fl.x) * diffuse.x,
(1.0 - fv.y) * (1.0 - fl.y) * diffuse.y,
(1.0 - fv.z) * (1.0 - fl.z) * diffuse.z,
),
)
}
/// Unconditional reflected-direction density for the specular NDF sampler.
/// Integrates to the acceptance probability, not necessarily one: invalid
/// microfacets and below-geometric/shading-hemisphere reflections are null events.
/// A mixture with a diffuse or environment sampler must use its own mixture PDF.
pub fn reflection_pdf(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<f64> {
let Some((v, _, h)) = self.directions(frame, geometric, view, light)? else { return Ok(0.0) };
finite_scalar(self.distribution_local(h) * h.z.max(0.0) / (4.0 * v.dot(h).max(0.0)))
}
/// One specular NDF attempt. `None` is a zero-contribution event and must
/// still count towards the consumer's sample denominator. Never retry until
/// success. Ray origins/visibility use the consumer's original geometric data.
pub fn sample_reflection(self, frame: VisualFrame, geometric: Vec3, view: Vec3, variates: [f64; 2]) -> Result<Option<ReflectionSample>> {
let geometric = frame.geometric(geometric)?;
let view = unit_direction(view)?;
let half = self.sample_normal(frame, variates)?;
let v = frame.local(view);
if v.z <= 0.0 || geometric.dot(view) <= 0.0 {
return Ok(None);
}
let vh = view.dot(half);
if vh <= 0.0 {
return Ok(None);
}
let light = unit_direction(half * (2.0 * vh) - view)?;
let l = frame.local(light);
if l.z <= 0.0 || geometric.dot(light) <= 0.0 {
return Ok(None);
}
let h = frame.local(half);
let pdf = self.reflection_pdf(frame, geometric, view, light)?;
if pdf <= 0.0 {
return Err(AnisotropicError::Arithmetic);
}
// Cancel D algebraically in f_spec * cos / pdf. No tiny-cosine division.
let weight = finite_rgb(self.fresnel(vh) * (self.visibility(v) * (self.visibility(l) * l.z) * vh / h.z))?;
Ok(Some(ReflectionSample { direction: light, pdf, weight }))
}
}
fn finite_scalar(value: f64) -> Result<f64> {
if value.is_finite() && value >= 0.0 { Ok(value) } else { Err(AnisotropicError::Arithmetic) }
}
fn finite_rgb(value: Vec3) -> Result<Vec3> {
if value.is_finite() && [value.x, value.y, value.z].into_iter().all(|c| c >= 0.0) { Ok(value) } else { Err(AnisotropicError::Arithmetic) }
}
#[cfg(test)]
#[path = "anisotropic_surface_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "anisotropic_vndf_tests.rs"]
mod vndf_tests;
@@ -0,0 +1,277 @@
use super::*;
fn frame() -> VisualFrame {
VisualFrame::new(Vec3::Z, Vec3::X).unwrap()
}
fn surface(rx: f64, ry: f64) -> AnisotropicSurface {
AnisotropicSurface::new(Vec3::new(0.8, 0.5, 0.2), 1.0, rx, ry, 1.5).unwrap()
}
fn close(a: f64, b: f64, tolerance: f64) {
assert!((a - b).abs() <= tolerance * (1.0 + a.abs().max(b.abs())), "{a} != {b}");
}
fn close_rgb(a: Vec3, b: Vec3, tolerance: f64) {
close(a.x, b.x, tolerance);
close(a.y, b.y, tolerance);
close(a.z, b.z, tolerance);
}
fn hemisphere(mu: f64, phi: f64) -> Vec3 {
let radius = (1.0 - mu * mu).sqrt();
Vec3::new(radius * phi.cos(), radius * phi.sin(), mu)
}
#[test]
fn constructor_rejects_hostile_parameters_on_both_axes() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, MIN_ROUGHNESS.next_down(), 1.01] {
assert!(AnisotropicSurface::new(Vec3::X, 1.0, bad, 0.5, 1.5).is_err());
assert!(AnisotropicSurface::new(Vec3::X, 1.0, 0.5, bad, 1.5).is_err());
}
for bad in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
assert!(AnisotropicSurface::new(Vec3::new(bad, 0.0, 0.0), 1.0, 0.5, 0.8, 1.5).is_err());
assert!(AnisotropicSurface::new(Vec3::X, bad, 0.5, 0.8, 1.5).is_err());
}
for bad in [f64::NAN, 0.99, 3.01] {
assert!(AnisotropicSurface::new(Vec3::X, 1.0, 0.5, 0.8, bad).is_err());
}
assert_eq!(surface(MIN_ROUGHNESS, 1.0).roughness(), [MIN_ROUGHNESS, 1.0]);
}
#[test]
fn frame_is_right_handed_and_requires_an_explicit_orthogonal_axis() {
let f = VisualFrame::new(Vec3::new(0.0, 0.0, 1e308), Vec3::new(1e-300, 0.0, 0.0)).unwrap();
assert_eq!(f.normal(), Vec3::Z);
assert_eq!(f.tangent(), Vec3::X);
assert_eq!(f.bitangent(), Vec3::Y);
for (n, t) in [(Vec3::ZERO, Vec3::X), (Vec3::Z, Vec3::Z), (Vec3::Z, Vec3::X + Vec3::Z), (Vec3::Z, Vec3::new(f64::NAN, 0.0, 0.0))] {
assert!(VisualFrame::new(n, t).is_err());
}
let tilted = VisualFrame::new(Vec3::new(0.3, 0.4, 1.0), Vec3::new(0.8, -0.6, 0.0)).unwrap();
close(tilted.tangent().dot(tilted.normal()), 0.0, 1e-14);
close_rgb(tilted.tangent().cross(tilted.bitangent()), tilted.normal(), 1e-14);
}
#[test]
fn normal_incidence_matches_independent_ggx_peak() {
let s = surface(0.3, 0.7);
let d = 1.0 / (PI * 0.3_f64.powi(2) * 0.7_f64.powi(2));
close(s.distribution(frame(), Vec3::Z).unwrap(), d, 1e-14);
close(s.normal_pdf(frame(), Vec3::Z).unwrap(), d, 1e-14);
close_rgb(s.brdf(frame(), Vec3::Z, Vec3::Z, Vec3::Z).unwrap(), Vec3::new(0.8, 0.5, 0.2) * (d / 4.0), 1e-14);
}
#[test]
fn isotropic_limit_matches_existing_opaque_contract() {
for r in [MIN_ROUGHNESS, 0.3, 0.7, 1.0] {
for metallic in [0.0, 0.5, 1.0] {
let color = Vec3::new(0.8, 0.5, 0.2);
let a = AnisotropicSurface::new(color, metallic, r, r, 1.5).unwrap();
let opaque = OpaqueSurface::new(color, metallic, r, 1.5).unwrap();
for (v, l) in [(Vec3::Z, Vec3::Z), (Vec3::new(0.4, 0.2, 0.8), Vec3::new(-0.2, 0.7, 0.6))] {
assert_eq!(a.brdf(frame(), Vec3::Z, v, l).unwrap(), opaque.brdf(Vec3::Z, v, l).unwrap());
if metallic == 1.0 {
close_rgb(a.specular_brdf(frame(), Vec3::Z, v, l).unwrap(), opaque.brdf(Vec3::Z, v, l).unwrap(), 1e-10);
}
}
}
}
}
#[test]
fn reciprocal_for_unequal_axes_and_mixed_metal_fraction() {
for metallic in [0.0, 0.4, 1.0] {
let s = AnisotropicSurface::new(Vec3::new(0.8, 0.5, 0.2), metallic, 0.2, 0.9, 2.0).unwrap();
for (v, l) in [(Vec3::new(0.4, 0.1, 1.0), Vec3::new(-0.3, 0.2, 0.8)), (Vec3::new(1.0, 0.0, 1e-300), Vec3::new(-1.0, 0.0, 1e-300))] {
close_rgb(s.brdf(frame(), Vec3::Z, v, l).unwrap(), s.brdf(frame(), Vec3::Z, l, v).unwrap(), 1e-12);
}
}
}
#[test]
fn ninety_degree_visual_rotation_swaps_the_brushed_response() {
let s = surface(0.25, 0.8);
let swapped = surface(0.8, 0.25);
let rotated = VisualFrame::new(Vec3::Z, Vec3::Y).unwrap();
let light = Vec3::new(0.6, 0.0, 0.8);
let initial = s.brdf(frame(), Vec3::Z, Vec3::Z, light).unwrap();
let turned = s.brdf(rotated, Vec3::Z, Vec3::Z, light).unwrap();
assert!((initial - turned).len() > 0.01);
close_rgb(turned, swapped.brdf(frame(), Vec3::Z, Vec3::Z, light).unwrap(), 1e-13);
let h = s.sample_normal(frame(), [0.3, 0.2]).unwrap();
let rotated_h = s.sample_normal(rotated, [0.3, 0.2]).unwrap();
close_rgb(rotated_h, Vec3::new(-h.y, h.x, h.z), 1e-14);
}
#[test]
fn geometric_and_shading_hemispheres_are_independent() {
let shading = VisualFrame::new(Vec3::new(0.8, 0.0, 0.6), Vec3::Y).unwrap();
let light = Vec3::new(0.99, 0.0, -0.1);
let s = surface(0.5, 0.8);
assert!(shading.normal().dot(light) > 0.0);
assert_eq!(s.brdf(shading, Vec3::Z, Vec3::Z, light).unwrap(), Vec3::ZERO);
assert_eq!(s.reflection_pdf(shading, Vec3::Z, Vec3::Z, light).unwrap(), 0.0);
assert!(s.brdf(frame(), -Vec3::Z, Vec3::Z, Vec3::Z).is_err());
assert_eq!(s.brdf(frame(), Vec3::Z, Vec3::Z, Vec3::X).unwrap(), Vec3::ZERO);
assert_eq!(s.brdf(frame(), Vec3::Z, -Vec3::Z, Vec3::Z).unwrap(), Vec3::ZERO);
}
#[test]
fn grazing_and_extreme_finite_direction_magnitudes_remain_finite() {
for (rx, ry) in [(MIN_ROUGHNESS, 1.0), (1.0, MIN_ROUGHNESS), (MIN_ROUGHNESS, MIN_ROUGHNESS)] {
let s = surface(rx, ry);
for (v, l) in [(Vec3::new(1.0, 0.0, 1e-300), Vec3::new(-1.0, 0.0, 1e-300)), (Vec3::new(1e308, 1e308, 1e308), Vec3::new(-1e-300, 0.0, 1e-300))]
{
let b = s.brdf(frame(), Vec3::Z, v, l).unwrap();
assert!(b.is_finite() && b.x >= 0.0 && b.y >= 0.0 && b.z >= 0.0);
}
for u in [0.0, f64::from_bits(1), 0.5, 1.0_f64.next_down()] {
let h = s.sample_normal(frame(), [u, 0.5]).unwrap();
assert!(h.is_finite() && h.z > 0.0);
assert!(s.normal_pdf(frame(), h).unwrap().is_finite());
}
let view = Vec3::new(1.0, 0.0, 1e-300);
let sample = s.sample_reflection(frame(), Vec3::Z, view, [0.0, 0.0]).unwrap().unwrap();
assert!(sample.pdf.is_finite() && sample.pdf > 0.0 && sample.weight.is_finite());
// A mathematical density beyond f64 is a typed refusal, never clamped.
assert_eq!(s.sample_reflection(frame(), Vec3::Z, Vec3::new(1.0, 0.0, f64::from_bits(1)), [0.0, 0.0]), Err(AnisotropicError::Arithmetic));
}
}
#[test]
fn malformed_directions_and_variates_fail_without_clamping() {
let s = surface(0.5, 0.8);
for bad in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::new(f64::INFINITY, 0.0, 1.0)] {
assert!(s.brdf(frame(), Vec3::Z, bad, Vec3::Z).is_err());
assert!(s.brdf(frame(), bad, Vec3::Z, Vec3::Z).is_err());
assert!(s.brdf(frame(), Vec3::Z, Vec3::Z, bad).is_err());
assert!(s.normal_pdf(frame(), bad).is_err());
}
for bad in [-0.1, 1.0, f64::NAN, f64::INFINITY] {
assert!(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [bad, 0.5]).is_err());
assert!(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [0.5, bad]).is_err());
}
}
#[test]
fn sampled_slopes_follow_the_independent_inverse_radial_cdf() {
let s = surface(0.3, 0.7);
for u in [0.0, 0.01, 0.3, 0.8, 0.9999] {
for azimuth in [0.0, 0.2, 0.5, 0.8] {
let h = s.sample_normal(frame(), [u, azimuth]).unwrap();
let sx = h.x / (0.3_f64.powi(2) * h.z);
let sy = h.y / (0.7_f64.powi(2) * h.z);
let radius2 = sx * sx + sy * sy;
close(radius2 / (1.0 + radius2), u, 1e-13);
close_rgb(h, s.sample_normal(frame(), [u, azimuth]).unwrap(), 0.0);
}
}
}
#[test]
fn reflection_samples_match_pdf_and_specular_throughput() {
let s = surface(0.4, 0.8);
let view = unit_direction(Vec3::new(0.2, 0.3, 1.0)).unwrap();
let mut accepted = 0;
let mut null = 0;
for i in 0..32 {
for j in 0..32 {
let uv = [(i as f64 + 0.5) / 32.0, (j as f64 + 0.5) / 32.0];
match s.sample_reflection(frame(), Vec3::Z, view, uv).unwrap() {
Some(sample) => {
accepted += 1;
close(sample.pdf, s.reflection_pdf(frame(), Vec3::Z, view, sample.direction).unwrap(), 1e-13);
let expected = s.specular_brdf(frame(), Vec3::Z, view, sample.direction).unwrap() * (sample.direction.z / sample.pdf);
close_rgb(sample.weight, expected, 1e-10);
}
None => null += 1,
}
}
}
assert!(accepted > 0 && null > 0);
assert_eq!(accepted + null, 1024);
}
#[test]
fn unit_roughness_normal_view_has_half_null_mass_and_uniform_reflection_pdf() {
let s = surface(1.0, 1.0);
assert!(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [0.75, 0.3]).unwrap().is_none());
let sample = s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [0.25, 0.3]).unwrap().unwrap();
close(sample.pdf, 1.0 / (4.0 * PI), 1e-14);
let mut accepted = 0;
for i in 0..1024 {
accepted += usize::from(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [(i as f64 + 0.5) / 1024.0, 0.3]).unwrap().is_some());
}
assert_eq!(accepted, 512);
// p(l)=1/(4*pi) over the upper hemisphere: valid mass exactly1/2.
close(s.reflection_pdf(frame(), Vec3::Z, Vec3::Z, Vec3::Z).unwrap() * (2.0 * PI), 0.5, 1e-14);
assert_eq!(s.reflection_pdf(frame(), Vec3::Z, Vec3::Z, -Vec3::Z).unwrap(), 0.0);
}
#[test]
fn invisible_microfacets_are_null_and_weights_exclude_the_diffuse_substrate() {
let s = AnisotropicSurface::new(Vec3::new(0.8, 0.5, 0.2), 0.0, 1.0, 1.0, 1.5).unwrap();
let view = unit_direction(Vec3::new(0.99, 0.0, 0.1)).unwrap();
assert!(s.sample_reflection(frame(), Vec3::Z, view, [0.9, 0.5]).unwrap().is_none());
let sample = s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [0.1, 0.2]).unwrap().unwrap();
let scale = sample.direction.z / sample.pdf;
let specular = s.specular_brdf(frame(), Vec3::Z, Vec3::Z, sample.direction).unwrap() * scale;
let total = s.brdf(frame(), Vec3::Z, Vec3::Z, sample.direction).unwrap() * scale;
close_rgb(sample.weight, specular, 1e-13);
assert!(total.x > sample.weight.x + 0.1);
assert_eq!(s.normal_pdf(frame(), -Vec3::Z).unwrap(), 0.0);
assert_eq!(s.normal_pdf(frame(), Vec3::X).unwrap(), 0.0);
}
#[test]
fn quadrature_distinguishes_normal_pdf_mass_from_valid_reflection_mass() {
// Finite midpoint grids resolve these broad lobes only. Narrowest admitted
// roughness is tested analytically and with inverse-CDF samples elsewhere.
const N: usize = 256;
for (rx, ry) in [(1.0, 1.0), (0.6, 0.9)] {
let s = surface(rx, ry);
let mut normal_mass = 0.0;
let mut valid_mass = 0.0;
let mut accepted = 0;
for i in 0..N {
for j in 0..N {
let u = (i as f64 + 0.5) / N as f64;
let azimuth = (j as f64 + 0.5) / N as f64;
let l = hemisphere(u, TAU * azimuth);
normal_mass += s.normal_pdf(frame(), l).unwrap();
valid_mass += s.reflection_pdf(frame(), Vec3::Z, Vec3::Z, l).unwrap();
accepted += usize::from(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [u, azimuth]).unwrap().is_some());
}
}
let domega = TAU / (N * N) as f64;
close(normal_mass * domega, 1.0, 0.001);
close(valid_mass * domega, accepted as f64 / (N * N) as f64, 0.003);
assert!(valid_mass * domega < 1.0);
}
}
#[test]
fn broad_lobe_importance_estimate_agrees_with_integrated_energy() {
// White metal, normal view, deterministic256² grids. A scoped consistency
// check, not universal energy certification or a variance/convergence claim.
const N: usize = 256;
let s = AnisotropicSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, 0.6, 0.9, 1.5).unwrap();
let mut integrated = Vec3::ZERO;
let mut sampled = Vec3::ZERO;
for i in 0..N {
for j in 0..N {
let uv = [(i as f64 + 0.5) / N as f64, (j as f64 + 0.5) / N as f64];
let light = hemisphere(uv[0], TAU * uv[1]);
integrated += s.specular_brdf(frame(), Vec3::Z, Vec3::Z, light).unwrap() * light.z;
if let Some(sample) = s.sample_reflection(frame(), Vec3::Z, Vec3::Z, uv).unwrap() {
sampled += sample.weight;
}
}
}
integrated = integrated * (TAU / (N * N) as f64);
sampled = sampled / (N * N) as f64; // Includes ALL null attempts.
assert!(integrated.x > 0.0 && integrated.x <= 1.001);
assert!(sampled.x > 0.0 && sampled.x <= 1.001);
close_rgb(integrated, sampled, 0.003);
}
+326
View File
@@ -0,0 +1,326 @@
//! VNDF proposal tests, written but not executed. Legacy NDF assertions stay intact.
use super::*;
fn frame() -> VisualFrame {
VisualFrame::new(Vec3::Z, Vec3::X).unwrap()
}
fn white(rx: f64, ry: f64) -> AnisotropicSurface {
AnisotropicSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, rx, ry, 1.5).unwrap()
}
fn close(a: f64, b: f64, tolerance: f64) {
assert!((a - b).abs() <= tolerance * (1.0 + a.abs().max(b.abs())), "{a} != {b}");
}
fn rotate(v: Vec3) -> Vec3 {
Vec3::new(-v.y, v.x, v.z)
}
#[test]
fn vndf_normal_incidence_has_analytic_mean_second_moment_and_half_null_mass() {
let surface = white(1.0, 1.0);
let (mut sum, mut second, mut nulls) = (0.0, 0.0, 0);
const N: usize = 4096;
for i in 0..N {
let u = (i as f64 + 0.5) / N as f64;
let value = match surface.sample_visible_reflection(frame(), Vec3::Z, Vec3::Z, [u, 0.3]).unwrap() {
Some(s) => {
close(s.direction.z, 1.0 - 2.0 * u, 1e-13);
close(s.pdf, 1.0 / (4.0 * PI), 1e-13);
close(s.weight.x, 2.0 * s.direction.z / (1.0 + s.direction.z), 1e-13);
s.weight.x
}
None => {
nulls += 1;
0.0
}
};
sum += value;
second += value * value;
}
assert_eq!(nulls, N / 2);
// Independent integrals of (1-2u)/(1-u) and its square on [0,1/2].
close(sum / N as f64, 1.0 - 2.0_f64.ln(), 1e-6);
close(second / N as f64, 3.0 - 4.0 * 2.0_f64.ln(), 1e-6);
}
#[test]
fn unit_roughness_visible_cap_has_analytic_grazing_pdf_and_acceptance_mass() {
const N: usize = 2048;
let s = white(1.0, 1.0);
for cosine in [1.0_f64, 0.2, 1e-12] {
let view = Vec3::new(((1.0 - cosine) * (1.0 + cosine)).sqrt(), 0.0, cosine);
let mut accepted = 0;
for i in 0..N {
let u = (i as f64 + 0.5) / N as f64;
if let Some(sample) = s.sample_visible_reflection(frame(), Vec3::Z, view, [u, 0.37]).unwrap() {
accepted += 1;
close(sample.pdf, 1.0 / (2.0 * PI * (1.0 + cosine)), 1e-10);
close(sample.weight.x, 2.0 * sample.direction.z / (1.0 + sample.direction.z), 1e-10);
}
}
close(accepted as f64 / N as f64, 1.0 / (1.0 + cosine), 1.0 / N as f64);
}
}
#[test]
fn anisotropic_visible_samples_match_unchanged_specular_bsdf_and_pdf() {
let s = AnisotropicSurface::new(Vec3::new(0.8, 0.5, 0.2), 0.4, 0.4, 0.8, 1.5).unwrap();
let v = unit_direction(Vec3::new(0.2, 0.3, 1.0)).unwrap();
let (mut accepted, mut nulls) = (0, 0);
for i in 0..32 {
for j in 0..32 {
let uv = [(i as f64 + 0.5) / 32.0, (j as f64 + 0.5) / 32.0];
match s.sample_visible_reflection(frame(), Vec3::Z, v, uv).unwrap() {
Some(sample) => {
accepted += 1;
let pdf = s.visible_reflection_pdf(frame(), Vec3::Z, v, sample.direction).unwrap();
close(sample.pdf, pdf, 1e-13);
let expected = s.specular_brdf(frame(), Vec3::Z, v, sample.direction).unwrap() * (sample.direction.z / pdf);
assert!((sample.weight - expected).len() < 1e-10);
assert!([sample.weight.x, sample.weight.y, sample.weight.z].into_iter().all(|w| (0.0..=1.0 + 1e-12).contains(&w)));
}
None => nulls += 1,
}
}
}
assert!(accepted > 0 && nulls > 0);
assert_eq!(accepted + nulls, 1024);
}
#[test]
fn anisotropic_visible_mapping_is_covariant_under_visual_frame_rotation() {
let s = white(0.2, 0.8);
let v = unit_direction(Vec3::new(0.4, 0.2, 1.0)).unwrap();
let turned = VisualFrame::new(Vec3::Z, Vec3::Y).unwrap();
for u in [[0.0, 0.0], [0.2, 0.3], [0.8, 0.7], [1.0_f64.next_down(), 0.5]] {
let a = s.sample_visible_normal(frame(), v, u).unwrap().unwrap();
let b = s.sample_visible_normal(turned, rotate(v), u).unwrap().unwrap();
assert!((rotate(a) - b).len() < 1e-12);
close(s.visible_normal_pdf(frame(), v, a).unwrap(), s.visible_normal_pdf(turned, rotate(v), b).unwrap(), 1e-11);
let a = s.sample_visible_reflection(frame(), Vec3::Z, v, u).unwrap();
let b = s.sample_visible_reflection(turned, Vec3::Z, rotate(v), u).unwrap();
match (a, b) {
(Some(a), Some(b)) => {
assert!((rotate(a.direction) - b.direction).len() < 1e-12);
assert!((a.weight - b.weight).len() < 1e-12);
close(a.pdf, b.pdf, 1e-11);
}
(None, None) => {}
_ => panic!("rotation changed null support"),
}
}
}
#[test]
fn visible_mapping_retains_finite_open_endpoints_and_grazing_limits() {
for (rx, ry) in [(MIN_ROUGHNESS, 1.0), (1.0, MIN_ROUGHNESS), (MIN_ROUGHNESS, MIN_ROUGHNESS), (1.0, 1.0)] {
let s = white(rx, ry);
for view in [Vec3::Z, Vec3::new(1.0, 0.0, 1e-300), Vec3::new(1.0, 0.0, f64::from_bits(1))] {
for u in [0.0, f64::from_bits(1), 0.5, 1.0_f64.next_down()] {
for phi in [0.0, 0.5, 1.0_f64.next_down()] {
let h = s.sample_visible_normal(frame(), view, [u, phi]).unwrap().unwrap();
assert!(h.is_finite() && h.z > 0.0);
close(h.len(), 1.0, 1e-13);
assert!(s.visible_normal_pdf(frame(), view, h).unwrap().is_finite());
if let Some(sample) = s.sample_visible_reflection(frame(), Vec3::Z, view, [u, phi]).unwrap() {
assert!(sample.pdf.is_finite() && sample.pdf > 0.0 && sample.weight.is_finite());
}
}
}
}
// This endpoint was an overflowing NDF-density control; VNDF cancels
// the tiny view cosine analytically instead of clamping that old PDF.
let sample = s.sample_visible_reflection(frame(), Vec3::Z, Vec3::new(1.0, 0.0, f64::from_bits(1)), [0.0, 0.0]).unwrap().unwrap();
assert!(sample.pdf.is_finite() && sample.weight.is_finite());
}
}
#[test]
fn visible_density_has_correct_support_and_rejects_hostile_variates() {
let s = white(0.4, 0.8);
for bad in [-0.1, 1.0, f64::NAN, f64::INFINITY] {
assert!(s.sample_visible_normal(frame(), Vec3::Z, [bad, 0.2]).is_err());
assert!(s.sample_visible_reflection(frame(), Vec3::Z, Vec3::Z, [0.2, bad]).is_err());
}
assert!(s.sample_visible_normal(frame(), Vec3::ZERO, [0.2, 0.2]).is_err());
assert_eq!(s.visible_normal_pdf(frame(), Vec3::Z, -Vec3::Z).unwrap(), 0.0);
assert_eq!(s.visible_normal_pdf(frame(), Vec3::Z, Vec3::X).unwrap(), 0.0);
assert_eq!(s.visible_normal_pdf(frame(), Vec3::new(1.0, 0.0, 0.01), Vec3::new(-1.0, 0.0, 0.1)).unwrap(), 0.0);
assert_eq!(s.visible_reflection_pdf(frame(), Vec3::Z, Vec3::Z, -Vec3::Z).unwrap(), 0.0);
assert!(s.sample_visible_reflection(frame(), Vec3::Z, -Vec3::Z, [0.2, 0.2]).unwrap().is_none());
let tilted = VisualFrame::new(Vec3::new(0.8, 0.0, 0.6), Vec3::Y).unwrap();
assert_eq!(s.visible_reflection_pdf(tilted, Vec3::Z, Vec3::Z, Vec3::new(0.99, 0.0, -0.1)).unwrap(), 0.0);
}
#[test]
fn unit_roughness_visible_normal_density_integrates_to_one() {
const N: usize = 256;
let s = white(1.0, 1.0);
for view in [Vec3::Z, unit_direction(Vec3::new(1.0, 0.0, 0.2)).unwrap()] {
let mut mass = 0.0;
for i in 0..N {
let z = (i as f64 + 0.5) / N as f64;
let radius = ((1.0 - z) * (1.0 + z)).sqrt();
for j in 0..N {
let phi = TAU * (j as f64 + 0.5) / N as f64;
mass += s.visible_normal_pdf(frame(), view, Vec3::new(radius * phi.cos(), radius * phi.sin(), z)).unwrap();
}
}
close(mass * TAU / (N * N) as f64, 1.0, 0.002);
}
}
fn oblique_rotation(v: Vec3) -> Vec3 {
// Original Rodrigues construction about an axis with all three components.
let axis = unit_direction(Vec3::new(1.0, 2.0, 3.0)).unwrap();
let (sine, cosine) = 0.71_f64.sin_cos();
v * cosine + axis.cross(v) * sine + axis * (axis.dot(v) * (1.0 - cosine))
}
#[test]
fn visible_samples_and_densities_follow_an_arbitrarily_tilted_visual_frame() {
let s = white(0.65, 0.9);
let tilted = VisualFrame::new(oblique_rotation(Vec3::Z), oblique_rotation(Vec3::X)).unwrap();
let v = unit_direction(Vec3::new(0.3, -0.2, 1.0)).unwrap();
let geometric = unit_direction(Vec3::new(0.1, 0.0, 1.0)).unwrap();
for uv in [[0.0, 0.0], [0.1, 0.3], [0.5, 0.7], [0.9, 0.2]] {
let h = s.sample_visible_normal(frame(), v, uv).unwrap().unwrap();
let rotated_h = s.sample_visible_normal(tilted, oblique_rotation(v), uv).unwrap().unwrap();
assert!((oblique_rotation(h) - rotated_h).len() < 1e-11);
close(s.visible_normal_pdf(frame(), v, h).unwrap(), s.visible_normal_pdf(tilted, oblique_rotation(v), rotated_h).unwrap(), 1e-10);
match (
s.sample_visible_reflection(frame(), geometric, v, uv).unwrap(),
s.sample_visible_reflection(tilted, oblique_rotation(geometric), oblique_rotation(v), uv).unwrap(),
) {
(Some(a), Some(b)) => {
assert!((oblique_rotation(a.direction) - b.direction).len() < 1e-10);
assert!((a.weight - b.weight).len() < 1e-10);
close(a.pdf, b.pdf, 1e-10);
}
(None, None) => {}
_ => panic!("tilting changed support away from a hemisphere boundary"),
}
}
}
#[test]
fn tilted_grazing_and_cap_opposition_are_finite_with_explicit_null_support() {
let tilted = VisualFrame::new(oblique_rotation(Vec3::Z), oblique_rotation(Vec3::X)).unwrap();
// A resolved 1e-6/1e-9 elevation, rather than an unrepresentable subnormal
// added to O(1) world components. The existing frame-Z subnormal test stays.
for elevation in [1e-6, 1e-9] {
let local_view = unit_direction(Vec3::new(1.0, 0.0, elevation)).unwrap();
let view = oblique_rotation(local_view);
for (rx, ry) in [(0.65, 0.9), (0.9, 0.65), (MIN_ROUGHNESS, 1.0)] {
let s = white(rx, ry);
for radial in [0.0, 0.5, 1.0_f64.next_down()] {
// Azimuth 1/2 opposes the stretched view's positive X component;
// radial→1 approaches the lower cap boundary (w+c cancellation).
for azimuth in [0.5_f64.next_down(), 0.5, 0.5_f64.next_up()] {
let uv = [radial, azimuth];
let h = s.sample_visible_normal(tilted, view, uv).unwrap().unwrap();
assert!(h.is_finite());
close(h.len(), 1.0, 1e-13);
let pdf_h = s.visible_normal_pdf(tilted, view, h).unwrap();
assert!(pdf_h.is_finite() && pdf_h >= 0.0);
if let Some(sample) = s.sample_visible_reflection(tilted, tilted.normal(), view, uv).unwrap() {
assert!(sample.direction.dot(tilted.normal()) > 0.0);
assert!(sample.pdf.is_finite() && sample.pdf > 0.0 && sample.weight.is_finite());
close(sample.pdf, s.visible_reflection_pdf(tilted, tilted.normal(), view, sample.direction).unwrap(), 1e-12);
}
}
}
// Both sides of the shading horizon have explicit support. This
// avoids inferring a sign from an exactly tangent, rounded dot.
assert!(s.sample_visible_normal(tilted, oblique_rotation(Vec3::new(1.0, 0.0, -elevation)), [0.2, 0.3]).unwrap().is_none());
assert_eq!(s.visible_normal_pdf(tilted, oblique_rotation(Vec3::new(1.0, 0.0, -elevation)), tilted.normal()).unwrap(), 0.0);
}
}
}
fn independent_visible_density(rx: f64, ry: f64, v: Vec3, h: Vec3) -> f64 {
if v.z <= 0.0 || h.z <= 0.0 || v.dot(h) <= 0.0 {
return 0.0;
}
let (ax, ay) = (rx * rx, ry * ry);
let q = (h.x / ax).powi(2) + (h.y / ay).powi(2) + h.z * h.z;
let distribution = 1.0 / (PI * ax * ay * q * q);
let masking_over_cosine = 2.0 / (v.z + (v.z * v.z + (ax * v.x).powi(2) + (ay * v.y).powi(2)).sqrt());
distribution * masking_over_cosine * v.dot(h)
}
#[test]
fn unequal_axes_normal_view_has_analytic_acceptance_and_unconditional_pdf_mass() {
const N: usize = 256;
for (rx, ry) in [(0.65_f64, 0.9_f64), (0.9, 0.65)] {
let s = white(rx, ry);
let (ax, ay) = (rx * rx, ry * ry);
// At v=n, tan²(theta_h)=u/(1-u)*(ax² cos²(phi)+ay² sin²(phi)).
// l.z>0 iff u<1/(1+ax² cos²(phi)+ay² sin²(phi)); integrating phi
// gives 1/sqrt((1+ax²)*(1+ay²)), not a density normalized to success.
let acceptance = 1.0 / ((1.0 + ax * ax) * (1.0 + ay * ay)).sqrt();
let (mut accepted, mut nulls, mut pdf_mass, mut normal_mass) = (0, 0, 0.0, 0.0);
for i in 0..N {
let u = (i as f64 + 0.5) / N as f64;
let radius = ((1.0 - u) * (1.0 + u)).sqrt();
for j in 0..N {
let t = (j as f64 + 0.5) / N as f64;
let phi = TAU * t;
let direction = Vec3::new(radius * phi.cos(), radius * phi.sin(), u);
let half = unit_direction(Vec3::Z + direction).unwrap();
let expected_pdf = independent_visible_density(rx, ry, Vec3::Z, half) / (4.0 * half.z);
close(s.visible_reflection_pdf(frame(), Vec3::Z, Vec3::Z, direction).unwrap(), expected_pdf, 1e-12);
pdf_mass += expected_pdf;
normal_mass += independent_visible_density(rx, ry, Vec3::Z, direction);
if s.sample_visible_reflection(frame(), Vec3::Z, Vec3::Z, [u, t]).unwrap().is_some() {
accepted += 1;
} else {
nulls += 1;
}
}
}
assert_eq!(accepted + nulls, N * N);
assert!(nulls > 0);
// Midpoint radial acceptance has <=1/N counting error per azimuth;
// smooth-azimuth and solid-angle quadrature remain separate checks.
close(accepted as f64 / (N * N) as f64, acceptance, 1.0 / N as f64);
close(pdf_mass * TAU / (N * N) as f64, acceptance, 0.002);
close(normal_mass * TAU / (N * N) as f64, 1.0, 0.002);
assert!(pdf_mass * TAU / ((N * N) as f64) < 1.0);
}
}
#[test]
fn unequal_axes_grazing_pdf_mass_matches_attempts_and_geometric_rejection() {
const N: usize = 256;
let s = white(0.65, 0.9);
let view = unit_direction(Vec3::new(1.0, 0.2, 0.1)).unwrap();
let geometric = unit_direction(Vec3::new(-0.05, 0.0, 1.0)).unwrap();
let (mut accepted, mut nulls, mut mass, mut normal_mass) = (0, 0, 0.0, 0.0);
for i in 0..N {
let u = (i as f64 + 0.5) / N as f64;
let radius = ((1.0 - u) * (1.0 + u)).sqrt();
for j in 0..N {
let t = (j as f64 + 0.5) / N as f64;
let phi = TAU * t;
let light = Vec3::new(radius * phi.cos(), radius * phi.sin(), u);
let half = unit_direction(view + light).unwrap();
let expected = if geometric.dot(light) > 0.0 { independent_visible_density(0.65, 0.9, view, half) / (4.0 * view.dot(half)) } else { 0.0 };
close(s.visible_reflection_pdf(frame(), geometric, view, light).unwrap(), expected, 1e-11);
mass += expected;
normal_mass += independent_visible_density(0.65, 0.9, view, light);
if s.sample_visible_reflection(frame(), geometric, view, [u, t]).unwrap().is_some() {
accepted += 1;
} else {
nulls += 1;
}
}
}
assert_eq!(accepted + nulls, N * N);
assert!(accepted > 0 && nulls > 0);
let integrated = mass * TAU / (N * N) as f64;
// A discontinuous geometric-hemisphere cut and two independently parameterized
// midpoint grids have a finite 0.01 agreement gate; no universal error bound.
close(integrated, accepted as f64 / (N * N) as f64, 0.01);
close(normal_mass * TAU / (N * N) as f64, 1.0, 0.002);
assert!(integrated > 0.0 && integrated < 1.0);
}
+201
View File
@@ -0,0 +1,201 @@
//! Original single-scattering GGX dielectric coat over the opaque surface oracle.
//! The substrate is attenuated at entry and exit; internal inter-layer scattering
//! and refraction of substrate directions are omitted. This is a bounded preview
//! model, not a full layered random walk. See docs/render-clear-coat.md.
use crate::surface::{MIN_ROUGHNESS, OpaqueSurface, SurfaceError, unit_direction};
use solidcraft_geom::Vec3;
use std::f64::consts::PI;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ClearCoat {
weight: f64,
roughness: f64,
ior: f64,
}
impl ClearCoat {
pub fn new(weight: f64, roughness: f64, ior: f64) -> Result<Self, SurfaceError> {
if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
return Err(SurfaceError::CoatWeight);
}
if !roughness.is_finite() || !(MIN_ROUGHNESS..=1.0).contains(&roughness) {
return Err(SurfaceError::Roughness);
}
if !ior.is_finite() || !(1.0..=3.0).contains(&ior) {
return Err(SurfaceError::DielectricIor);
}
Ok(Self { weight, roughness, ior })
}
pub fn weight(self) -> f64 {
self.weight
}
pub fn roughness(self) -> f64 {
self.roughness
}
pub fn ior(self) -> f64 {
self.ior
}
fn fresnel(self, c: f64) -> f64 {
let ratio = (self.ior - 1.0) / (self.ior + 1.0);
let f0 = ratio * ratio;
f0 + (1.0 - f0) * (1.0 - c).powi(5)
}
/// Upper bound on coat directional reflectance: over the upper hemisphere,
/// v.h >= nv/sqrt(2*(1+sqrt(1-nv^2))). Schlick decreases with v.h and
/// the unit-Fresnel single-scattering Smith GGX lobe reflects at most one.
fn energy_bound(self, cosine: f64) -> f64 {
self.fresnel(cosine / (2.0 * (1.0 + (1.0 - cosine * cosine).max(0.0).sqrt())).sqrt())
}
pub(crate) fn layer(self, base: Vec3, nv: f64, nl: f64, nh: f64, vh: f64) -> Vec3 {
let a2 = self.roughness.powi(4);
let denom = (1.0 - nh * nh) + nh * nh * a2;
let d = a2 / (PI * denom * denom);
let visibility = |c: f64| 2.0 / (c + (a2 + (1.0 - a2) * c * c).sqrt());
let coat = self.weight * self.fresnel(vh) * d * 0.25 * visibility(nv) * visibility(nl);
let attenuation = (1.0 - self.weight * self.energy_bound(nv)) * (1.0 - self.weight * self.energy_bound(nl));
base * attenuation + Vec3::new(coat, coat, coat)
}
}
/// Conditional opaque-lobe PDF, including the coat and base mixture. Rejected
/// below-surface microfacet samples retain their mass; never renormalize them.
pub fn surface_pdf(surface: OpaqueSurface, normal: Vec3, view: Vec3, light: Vec3) -> Result<f64, SurfaceError> {
let n = unit_direction(normal)?;
let v = unit_direction(view)?;
let l = unit_direction(light)?;
let nl = n.dot(l);
if nl <= 0.0 || n.dot(v) <= 0.0 {
return Ok(0.0);
}
let h = unit_direction(v + l)?;
let nh = n.dot(h).clamp(0.0, 1.0);
let vh = v.dot(h).max(1e-20);
let ggx = |roughness: f64| {
let a2 = roughness.powi(4);
let d = (1.0 - nh * nh) + nh * nh * a2;
a2 / (PI * d * d) * nh / (4.0 * vh)
};
let base = 0.5 * nl / PI + 0.5 * ggx(surface.roughness());
Ok(surface.clear_coat().map_or(base, |coat| {
let p = coat.weight() * 0.5;
(1.0 - p) * base + p * ggx(coat.roughness())
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn admission_zero_coat_and_reciprocity() {
for bad in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
assert!(ClearCoat::new(bad, 0.2, 1.5).is_err());
}
for bad in [0.0, 0.044, 1.1, f64::NAN] {
assert!(ClearCoat::new(1.0, bad, 1.5).is_err());
}
for bad in [0.99, 3.01, f64::NAN] {
assert!(ClearCoat::new(1.0, 0.2, bad).is_err());
}
let base = OpaqueSurface::new(Vec3::new(0.01, 0.02, 0.03), 0.0, 0.3, 1.5).unwrap();
assert_eq!(base, base.with_clear_coat(ClearCoat::new(0.0, 0.1, 1.5).unwrap()));
let coated = base.with_clear_coat(ClearCoat::new(1.0, 0.12, 1.5).unwrap());
let v = Vec3::new(0.5, 0.0, 0.8);
let l = Vec3::new(-0.3, 0.2, 0.6);
assert!((coated.brdf(Vec3::Z, v, l).unwrap() - coated.brdf(Vec3::Z, l, v).unwrap()).len() < 1e-12);
assert!(coated.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap().x > base.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap().x);
// Normal incidence: D=1/(pi*roughness^4), G=1, Fresnel=.04.
let expected = base.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap() * (1.0 - ClearCoat::new(1.0, 0.12, 1.5).unwrap().energy_bound(1.0)).powi(2)
+ Vec3::new(1.0, 1.0, 1.0) * (0.04 / (4.0 * PI * 0.12_f64.powi(4)));
assert!((coated.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap() - expected).len() < 1e-10);
}
#[test]
fn layered_white_furnace_and_pdf_quadrature() {
// Deterministic quadrature resolves these lobes; it is not a proof for
// arbitrary roughness. Narrow coat lobes have separate GPU oracle checks.
for roughness in [0.25, 0.5, 1.0] {
for metallic in [0.0, 1.0] {
for cosine in [0.2_f64, 1.0] {
let s = OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), metallic, 0.5, 1.5)
.unwrap()
.with_clear_coat(ClearCoat::new(1.0, roughness, 1.5).unwrap());
let v = Vec3::new((1.0 - cosine * cosine).sqrt(), 0.0, cosine);
let mut energy = 0.0;
let mut probability = 0.0;
for i in 0..256 {
let mu = (f64::from(i) + 0.5) / 256.0;
let r = (1.0 - mu * mu).sqrt();
for j in 0..256 {
let phi = 2.0 * PI * (f64::from(j) + 0.5) / 256.0;
let l = Vec3::new(r * phi.cos(), r * phi.sin(), mu);
energy += s.brdf(Vec3::Z, v, l).unwrap().x * mu;
probability += surface_pdf(s, Vec3::Z, v, l).unwrap();
}
}
energy *= 2.0 * PI / 65536.0;
probability *= 2.0 * PI / 65536.0;
assert!(energy <= 1.025, "roughness={roughness} metal={metallic} cosine={cosine} energy={energy}");
assert!((0.2..=1.005).contains(&probability), "{probability}");
}
}
}
}
#[test]
fn conservative_bound_and_layer_furnace_include_shiny_bases_and_grazing() {
const COUNT: u32 = 4096;
let mut maximum = 0.0_f64;
for base_roughness in [0.045, 0.12, 0.5, 1.0] {
for coat_roughness in [0.12, 0.5, 1.0] {
for weight in [0.25, 1.0] {
for ior in [1.0, 1.5, 3.0] {
for cosine in [0.02_f64, 0.2, 1.0] {
for metallic in [0.0, 1.0] {
let coat = ClearCoat::new(weight, coat_roughness, ior).unwrap();
let surface =
OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), metallic, base_roughness, 1.5).unwrap().with_clear_coat(coat);
let v = Vec3::new((1.0 - cosine * cosine).sqrt(), 0.0, cosine);
let p = weight * 0.5;
let mut energy = 0.0;
// Integrate each proposal with its mixture probability.
// NDF importance quadrature resolves sharp lobes, unlike
// a uniform hemisphere grid at roughness 0.045.
for (component, probability) in [(0, (1.0 - p) * 0.5), (1, (1.0 - p) * 0.5), (2, p)] {
for i in 0..COUNT {
let x = (f64::from(i) + 0.5) / f64::from(COUNT);
let phi = 2.0 * PI * f64::from(i.reverse_bits()) / 4294967296.0;
let l = if component == 0 {
Vec3::new(x.sqrt() * phi.cos(), x.sqrt() * phi.sin(), (1.0 - x).sqrt())
} else {
let r = if component == 1 { base_roughness } else { coat_roughness };
let c = ((1.0 - x) / (1.0 + (r.powi(4) - 1.0) * x)).sqrt();
let radius = (1.0 - c * c).sqrt();
let h = Vec3::new(radius * phi.cos(), radius * phi.sin(), c);
h * (2.0 * v.dot(h)) - v
};
if l.z <= 0.0 {
continue;
}
let h = unit_direction(v + l).unwrap();
assert!(coat.fresnel(v.dot(h).clamp(0.0, 1.0)) <= coat.energy_bound(cosine) + 1e-12);
let pdf = surface_pdf(surface, Vec3::Z, v, l).unwrap();
energy += probability * surface.brdf(Vec3::Z, v, l).unwrap().x * l.z / pdf / f64::from(COUNT);
}
}
maximum = maximum.max(energy);
assert!(
energy.is_finite() && energy <= 1.003,
"base={base_roughness}, coat={coat_roughness}, w={weight}, ior={ior}, nv={cosine}, m={metallic}: {energy}"
);
}
}
}
}
}
}
eprintln!("maximum conservative layered furnace reflectance: {maximum}");
for cosine in [0.0, 1e-300, 1e-20, 0.02, 1.0] {
let bound = ClearCoat::new(1.0, 0.12, 1.5).unwrap().energy_bound(cosine);
assert!(bound.is_finite() && (0.0..=1.0).contains(&bound));
}
}
}
@@ -0,0 +1,78 @@
//! Original MIT OR Apache-2.0 analytic fixtures for the shared CPU/GPU dielectric contract.
//! Provenance: elementary Fresnel s/p equations, Snell law and Beer-Lambert attenuation.
//! No external assets or renderer outputs. Tolerances are absolute f64 errors stated per assertion.
//! Import this test module with the optical math module; only the API adapter needs changing.
use super::*;
#[test]
fn oblique_polarizations_and_snell_match_independent_angle_equations() {
let brewster = 1.5_f64.atan();
for angle in [0.3, brewster - 1e-4, brewster, brewster + 1e-4, 1.2] {
let transmitted_angle = (angle.sin() / 1.5).asin();
let expected_s = ((angle - transmitted_angle).sin() / (angle + transmitted_angle).sin()).powi(2);
let expected_p = ((angle - transmitted_angle).tan() / (angle + transmitted_angle).tan()).powi(2);
let actual = fresnel(angle.cos(), 1.0, 1.5).unwrap();
assert!((actual.s - expected_s).abs() < 1e-14);
assert!((actual.p - expected_p).abs() < 1e-14);
let incident = Vec3::new(angle.sin(), 0.0, -angle.cos());
let outgoing = refract(incident, Vec3::Z, 1.0, 1.5).unwrap().unwrap();
assert!((outgoing.x - transmitted_angle.sin()).abs() < 1e-14);
assert!((outgoing.z + transmitted_angle.cos()).abs() < 1e-14);
let reversed = refract(-outgoing, -Vec3::Z, 1.5, 1.0).unwrap().unwrap();
assert!((reversed + incident).len() < 1e-13);
}
let critical = (1.0_f64 / 1.5).asin();
assert!(fresnel((critical - 1e-5).cos(), 1.5, 1.0).unwrap().transmitted_cosine.is_some());
assert!(fresnel((critical + 1e-5).cos(), 1.5, 1.0).unwrap().transmitted_cosine.is_none());
assert!(refract(Vec3::new(0.9, 0.0, -(1.0_f64 - 0.81).sqrt()), Vec3::Z, 1.5, 1.0).unwrap().is_none());
let zero_sheet = Transmission::new(Vec3::new(100.0, 100.0, 100.0), Some(0.0)).unwrap();
let (r, t) = slab(zero_sheet, 0.0, 1.0, 1.0, 1.5).unwrap();
assert!((r.x + t.x - 1.0).abs() < 1e-14);
assert!((t.x - 0.96_f64.powi(2) / (1.0 - 0.04_f64.powi(2))).abs() < 1e-14);
}
#[test]
fn exact_fresnel_brewster_critical_and_index_matched_limits() {
for n in [1.0_f64, 1.33, 1.5, 3.0] {
assert!((fresnel(1.0, 1.0, n).unwrap().reflectance() - ((n - 1.0) / (n + 1.0)).powi(2)).abs() < 1e-15);
assert!(fresnel(n.atan().cos(), 1.0, n).unwrap().p < 1e-30);
}
assert_eq!(fresnel(0.5, 1.5, 1.0).unwrap().transmitted_cosine, None);
assert_eq!(fresnel(0.0, 1.0, 1.0).unwrap().reflectance(), 0.0);
assert_eq!(fresnel(0.0, 1.0, 1.5).unwrap().reflectance(), 1.0);
for cosine in [0.01, 0.3, 0.9, 1.0] {
let f = fresnel(cosine, 1.0, 1.5).unwrap();
let reverse = fresnel(f.transmitted_cosine.unwrap(), 1.5, 1.0).unwrap();
assert!((f.reflectance() - reverse.reflectance()).abs() < 1e-12);
}
}
#[test]
fn beer_lambert_slab_and_lossless_energy() {
let t = Transmission::new(Vec3::new(-0.5_f64.ln(), -0.8_f64.ln(), 0.0) / 10.0, None).unwrap();
assert!((t.attenuation(20.0).unwrap() - Vec3::new(0.25, 0.64, 1.0)).len() < 1e-15);
let (r, through) = slab(t, 10.0, 1.0, 1.0, 1.5).unwrap();
assert!((through.x - 0.96_f64.powi(2) * 0.5 / (1.0 - 0.04_f64.powi(2) * 0.25)).abs() < 1e-15);
assert!((r.z + through.z - 1.0).abs() < 1e-15);
assert!(r.x + through.x < 1.0);
let clear = Transmission::new(Vec3::ZERO, None).unwrap();
for cosine in [0.001, 0.1, 0.5, 1.0] {
let (r, t) = slab(clear, 1.0, cosine, 1.0, 1.5).unwrap();
assert!((r.x + t.x - 1.0).abs() < 1e-13);
}
}
#[test]
fn hostile_optics_are_rejected_without_clamping() {
for v in [f64::NAN, f64::INFINITY, -1.0, 100.01] {
assert!(Transmission::new(Vec3::new(v, 0.0, 0.0), None).is_err());
}
for v in [f64::NAN, f64::INFINITY, -1.0, 10000.01] {
assert!(Transmission::new(Vec3::ZERO, Some(v)).is_err());
}
assert!(Transmission::new(Vec3::ZERO, Some(0.0)).is_ok());
assert!(fresnel(f64::NAN, 1.0, 1.5).is_err());
assert!(fresnel(0.5, f64::INFINITY, 1.5).is_err());
assert!(refract(Vec3::ZERO, Vec3::Z, 1.0, 1.5).is_err());
assert!(refract(Vec3::Z, Vec3::Z, 1.0, 1.5).is_err());
}
+226
View File
@@ -0,0 +1,226 @@
//! Bounded linear RGB statistics, independent of rendering and display transforms.
//! Samples use the renderer's linear-sRGB primaries; source HDR conversion is a
//! caller prerequisite. Display-sRGB primary backgrounds need a separate coverage
//! policy before camera samples can be mixed; this module does not invent one.
//!
//! The caller reserves `required_bytes` in its EXISTING job/host lease before
//! construction, and supplies a charge callback debiting that job's cumulative
//! work budget. This module creates no pool, thread, cache or cancellation token.
//! Fallible allocation checks actual capacity against that same admission.
//!
//! Samples for each pixel must arrive in the same order for bitwise reproducible
//! results. Splitting contiguous updates into smaller calls preserves that order;
//! arbitrary floating-point reductions or reordered samples are not equivalent.
//!
//! `complete_pass` borrows the film immutably and refuses uneven/empty counts.
//! Its iterator exposes linear means only; the consumer applies exposure, tone
//! mapping and encoding ONCE, in bounded cancellable chunks. Publishing requires
//! a fully resolved owned snapshot with its own host lease, generation check and
//! cancellation/publication lock. Neither a partial update nor this borrowed view
//! is a publishable frame. Progressive host/UI wiring is a separate integration.
use crate::job::{CancelToken, ErrorCode, JobError, Result};
pub const MAX_DIMENSION: u32 = 16_384;
pub const MAX_PIXELS: usize = 16_777_216;
pub const MAX_SAMPLES: u32 = 1_048_576;
pub const MAX_UPDATE: usize = 256;
pub const MAX_BYTES: u64 = 384 * 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Pixel {
mean: [f64; 3],
m2: [f64; 3],
count: u32,
}
/// Unbiased sample variance, zero by convention before two samples.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PixelStatistics {
pub mean: [f64; 3],
pub variance: [f64; 3],
pub count: u32,
}
/// Storage stays private so callers cannot bypass finite/count invariants.
pub struct FilmAccumulator {
width: u32,
height: u32,
sample_limit: u32,
pixels: Vec<Pixel>,
}
/// An immutable uniform pass; not an owned or publishable preview image.
pub struct CompletePass<'a> {
film: &'a FilmAccumulator,
samples_per_pixel: u32,
}
fn invalid(message: &'static str) -> JobError {
JobError::new(ErrorCode::InvalidRequest, message)
}
fn limit(message: &'static str) -> JobError {
JobError::new(ErrorCode::LimitExceeded, message)
}
fn dimensions(width: u32, height: u32, sample_limit: u32) -> Result<usize> {
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION || sample_limit == 0 || sample_limit > MAX_SAMPLES {
return Err(invalid("invalid film dimensions or sample limit"));
}
let pixels = u64::from(width).checked_mul(u64::from(height)).ok_or_else(|| limit("film dimensions overflow"))?;
usize::try_from(pixels).ok().filter(|n| *n <= MAX_PIXELS).ok_or_else(|| limit("film pixel limit exceeded"))
}
fn bytes_for_capacity(capacity: usize) -> Result<u64> {
let bytes = u64::try_from(capacity)
.ok()
.and_then(|n| n.checked_mul(std::mem::size_of::<Pixel>() as u64))
.and_then(|n| n.checked_add(std::mem::size_of::<FilmAccumulator>() as u64))
.and_then(|n| n.checked_add(std::mem::size_of::<[Pixel; MAX_UPDATE]>() as u64))
.filter(|n| *n <= MAX_BYTES)
.ok_or_else(|| limit("film storage limit exceeded"))?;
Ok(bytes)
}
impl Pixel {
fn updated(self, rgb: [f64; 3], sample_limit: u32) -> Result<Self> {
if rgb.iter().any(|c| !c.is_finite() || *c < 0.0) {
return Err(invalid("film samples must be finite nonnegative linear RGB"));
}
let count = self.count.checked_add(1).filter(|n| *n <= sample_limit).ok_or_else(|| limit("film sample limit exceeded"))?;
let mut result = self;
for ((mean, m2), value) in result.mean.iter_mut().zip(result.m2.iter_mut()).zip(rgb) {
let delta = value - *mean;
*mean += delta / f64::from(count);
*m2 += delta * (value - *mean);
if !mean.is_finite() || !m2.is_finite() || *mean < 0.0 || *m2 < 0.0 {
return Err(invalid("film statistics exceed finite numeric range"));
}
}
result.count = count;
Ok(result)
}
fn statistics(self) -> PixelStatistics {
let denominator = f64::from(self.count.saturating_sub(1).max(1));
PixelStatistics { mean: self.mean, variance: self.m2.map(|m2| m2 / denominator), count: self.count }
}
}
impl FilmAccumulator {
/// Required host-admitted storage, including the film object and the bounded
/// atomic-update stack scratch used while this film is alive. Allocator
/// bookkeeping is outside this byte contract; actual Vec capacity is checked.
pub fn required_bytes(width: u32, height: u32, sample_limit: u32) -> Result<u64> {
bytes_for_capacity(dimensions(width, height, sample_limit)?)
}
/// `admitted_bytes` is an existing host lease amount, not a second pool.
/// The caller must preserve that lease for this object's entire lifetime.
pub fn new(
width: u32,
height: u32,
sample_limit: u32,
admitted_bytes: u64,
cancel: &CancelToken,
charge: &mut impl FnMut(u64) -> Result<()>,
) -> Result<Self> {
cancel.check()?;
let count = dimensions(width, height, sample_limit)?;
let required = bytes_for_capacity(count)?;
if admitted_bytes < required || admitted_bytes > MAX_BYTES {
return Err(limit("film is not covered by caller admission"));
}
let mut pixels = Vec::new();
pixels.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve film storage"))?;
if bytes_for_capacity(pixels.capacity())? > admitted_bytes {
return Err(limit("actual film capacity exceeds caller admission"));
}
for _ in 0..count {
cancel.check()?;
charge(1)?;
pixels.push(Pixel::default());
}
cancel.check()?;
Ok(Self { width, height, sample_limit, pixels })
}
pub fn dimensions(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Actual retained capacity plus object and maximum update scratch allowance.
pub fn allocated_bytes(&self) -> Result<u64> {
bytes_for_capacity(self.pixels.capacity())
}
/// For diagnostics/variance decisions, not partially published image output.
pub fn pixel_statistics(&self, index: usize) -> Result<PixelStatistics> {
self.pixels.get(index).copied().map(Pixel::statistics).ok_or_else(|| invalid("film pixel index out of range"))
}
/// One sample per consecutive pixel, at most MAX_UPDATE. Validate the whole
/// update before mutation. Validation/commit work is charged up front; failed
/// work/cancel/input checks leave the film unchanged (budget stays consumed).
/// After the last cancellation check, bounded commit is the linearization
/// point: cancellation during commit is observed by the caller before publish.
pub fn add_range(&mut self, start: usize, samples: &[[f64; 3]], cancel: &CancelToken, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<()> {
cancel.check()?;
if samples.is_empty() || samples.len() > MAX_UPDATE {
return Err(invalid("film update must contain 1..256 samples"));
}
let end = start.checked_add(samples.len()).ok_or_else(|| invalid("film update range overflow"))?;
let pixels = self.pixels.get(start..end).ok_or_else(|| invalid("film update range out of bounds"))?;
// Fixed bounded stack scratch gives atomic commit without a second heap
// buffer or recomputation that might fail after earlier pixels mutated.
let mut staged = [Pixel::default(); MAX_UPDATE];
for ((pixel, sample), target) in pixels.iter().zip(samples).zip(staged.iter_mut()) {
cancel.check()?;
charge(2)?;
*target = pixel.updated(*sample, self.sample_limit)?;
}
cancel.check()?;
let target = self.pixels.get_mut(start..end).ok_or_else(|| invalid("film update range changed"))?;
target.copy_from_slice(staged.get(..samples.len()).ok_or_else(|| invalid("film staging range invalid"))?);
Ok(())
}
/// No allocation and no display transform. Each checked pixel charges one
/// unit to the same job and checks its cancellation token.
pub fn complete_pass<'a>(&'a self, cancel: &CancelToken, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<CompletePass<'a>> {
cancel.check()?;
let count = self.pixels.first().map(|p| p.count).ok_or_else(|| invalid("film has no pixels"))?;
if count == 0 {
return Err(invalid("film has no complete pass"));
}
for pixel in &self.pixels {
cancel.check()?;
charge(1)?;
if pixel.count != count {
return Err(invalid("film pass is incomplete"));
}
}
cancel.check()?;
Ok(CompletePass { film: self, samples_per_pixel: count })
}
}
impl CompletePass<'_> {
pub fn samples_per_pixel(&self) -> u32 {
self.samples_per_pixel
}
pub fn dimensions(&self) -> (u32, u32) {
self.film.dimensions()
}
/// Borrowed immutable linear means. Resolving/copying/encoding and its work
/// accounting/cancellation remain caller-owned; no output buffer is allocated.
pub fn pixels(&self) -> impl ExactSizeIterator<Item = [f64; 3]> + '_ {
self.film.pixels.iter().map(|p| p.mean)
}
}
#[cfg(test)]
#[path = "film_tests.rs"]
mod tests;
+191
View File
@@ -0,0 +1,191 @@
use super::*;
fn film(width: u32, height: u32, limit: u32) -> FilmAccumulator {
let bytes = FilmAccumulator::required_bytes(width, height, limit).unwrap();
FilmAccumulator::new(width, height, limit, bytes, &CancelToken::default(), &mut |_| Ok(())).unwrap()
}
fn add(film: &mut FilmAccumulator, start: usize, values: &[[f64; 3]]) {
film.add_range(start, values, &CancelToken::default(), &mut |_| Ok(())).unwrap();
}
#[test]
fn constant_and_alternating_samples_have_analytic_statistics() {
let mut film = film(2, 1, 4);
for value in [0.0, 2.0, 0.0, 2.0] {
add(&mut film, 0, &[[7.0, 3.0, 1.0], [value; 3]]);
}
assert_eq!(film.pixel_statistics(0).unwrap(), PixelStatistics { mean: [7.0, 3.0, 1.0], variance: [0.0; 3], count: 4 });
let statistics = film.pixel_statistics(1).unwrap();
assert_eq!(statistics.mean, [1.0; 3]);
assert_eq!(statistics.variance, [4.0 / 3.0; 3]);
}
#[test]
fn complete_pass_refuses_empty_or_uneven_counts_and_keeps_linear_values() {
let mut film = film(2, 1, 3);
assert!(film.complete_pass(&CancelToken::default(), &mut |_| Ok(())).is_err());
add(&mut film, 0, &[[20.0, 2.0, 0.0]]);
assert!(film.complete_pass(&CancelToken::default(), &mut |_| Ok(())).is_err());
add(&mut film, 1, &[[4.0, 8.0, 16.0]]);
let pass = film.complete_pass(&CancelToken::default(), &mut |_| Ok(())).unwrap();
assert_eq!(pass.samples_per_pixel(), 1);
assert_eq!(pass.dimensions(), (2, 1));
assert_eq!(pass.pixels().collect::<Vec<_>>(), vec![[20.0, 2.0, 0.0], [4.0, 8.0, 16.0]]);
}
#[test]
fn invalid_sample_at_end_leaves_entire_update_unchanged() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
let mut film = film(2, 1, 3);
add(&mut film, 0, &[[1.0; 3], [2.0; 3]]);
let before = film.pixels.clone();
assert_eq!(film.add_range(0, &[[3.0; 3], [bad; 3]], &CancelToken::default(), &mut |_| Ok(())).unwrap_err().code, ErrorCode::InvalidRequest);
assert_eq!(film.pixels, before);
}
}
#[test]
fn sample_limit_and_numeric_overflow_refuse_atomically() {
let mut limited = film(2, 1, 1);
add(&mut limited, 1, &[[1.0; 3]]);
let before = limited.pixels.clone();
assert_eq!(limited.add_range(0, &[[1.0; 3]; 2], &CancelToken::default(), &mut |_| Ok(())).unwrap_err().code, ErrorCode::LimitExceeded);
assert_eq!(limited.pixels, before);
let mut extreme = film(2, 1, 3);
add(&mut extreme, 0, &[[1.0; 3], [f64::MAX; 3]]);
let before = extreme.pixels.clone();
assert!(extreme.add_range(0, &[[2.0; 3], [0.0; 3]], &CancelToken::default(), &mut |_| Ok(())).is_err());
assert_eq!(extreme.pixels, before);
}
#[test]
fn dimensions_bytes_and_actual_capacity_are_checked() {
for (width, height, samples) in [(0, 1, 1), (1, 0, 1), (u32::MAX, u32::MAX, 1), (1, 1, 0), (1, 1, MAX_SAMPLES + 1)] {
assert!(FilmAccumulator::required_bytes(width, height, samples).is_err());
}
assert!(FilmAccumulator::required_bytes(MAX_DIMENSION, MAX_DIMENSION, 1).is_err());
assert!(bytes_for_capacity(usize::MAX).is_err());
let bytes = FilmAccumulator::required_bytes(2, 2, 1).unwrap();
assert!(FilmAccumulator::new(2, 2, 1, bytes - 1, &CancelToken::default(), &mut |_| Ok(())).is_err());
let film = film(2, 2, 1);
assert!(film.allocated_bytes().unwrap() <= bytes);
assert_eq!(film.allocated_bytes().unwrap(), bytes_for_capacity(film.pixels.capacity()).unwrap());
assert!(film.allocated_bytes().unwrap() >= (std::mem::size_of::<Pixel>() * (4 + MAX_UPDATE)) as u64);
}
#[test]
fn invalid_ranges_and_oversized_updates_preserve_state() {
let mut film = film(2, 1, 1);
for (start, values) in [(0, Vec::new()), (usize::MAX, vec![[1.0; 3]]), (2, vec![[1.0; 3]]), (0, vec![[1.0; 3]; MAX_UPDATE + 1])] {
assert!(film.add_range(start, &values, &CancelToken::default(), &mut |_| Ok(())).is_err());
assert!(film.pixels.iter().all(|p| *p == Pixel::default()));
}
assert!(film.pixel_statistics(2).is_err());
}
#[test]
fn incremental_partitions_preserve_bitwise_results_in_sample_order() {
let mut whole = film(4, 1, 3);
let mut partitioned = film(4, 1, 3);
for values in [
[[1.0; 3], [1e12; 3], [0.25; 3], [4.0; 3]],
[[2.0; 3], [1e12 + 1.0; 3], [0.5; 3], [2.0; 3]],
[[4.0; 3], [1e12 - 1.0; 3], [0.75; 3], [0.0; 3]],
] {
add(&mut whole, 0, &values);
add(&mut partitioned, 0, &values[..1]);
add(&mut partitioned, 1, &values[1..3]);
add(&mut partitioned, 3, &values[3..]);
}
assert_eq!(whole.pixels, partitioned.pixels);
assert_eq!(whole.pixel_statistics(1).unwrap().variance, [1.0; 3]);
}
#[test]
fn cancellation_and_work_failure_preserve_all_pixels() {
let mut film = film(2, 1, 3);
let cancel = CancelToken::default();
let observer = cancel.clone();
assert_eq!(
film.add_range(0, &[[1.0; 3]; 2], &cancel, &mut |_| {
observer.cancel();
Ok(())
})
.unwrap_err()
.code,
ErrorCode::Cancelled
);
assert!(film.pixels.iter().all(|p| *p == Pixel::default()));
let mut charged = 0;
assert_eq!(
film.add_range(0, &[[1.0; 3]; 2], &CancelToken::default(), &mut |units| {
charged += units;
if charged > 2 { Err(limit("job work exhausted")) } else { Ok(()) }
})
.unwrap_err()
.code,
ErrorCode::LimitExceeded
);
assert_eq!(charged, 4);
assert!(film.pixels.iter().all(|p| *p == Pixel::default()));
}
#[test]
fn construction_and_complete_pass_share_caller_work_and_cancel() {
let bytes = FilmAccumulator::required_bytes(2, 1, 1).unwrap();
let cancel = CancelToken::default();
cancel.cancel();
assert!(FilmAccumulator::new(2, 1, 1, bytes, &cancel, &mut |_| panic!("cancelled construction charged work")).is_err());
let mut charged = 0;
let mut film = FilmAccumulator::new(2, 1, 1, bytes, &CancelToken::default(), &mut |n| {
charged += n;
Ok(())
})
.unwrap();
assert_eq!(charged, 2);
add(&mut film, 0, &[[1.0; 3]; 2]);
assert!(film.complete_pass(&CancelToken::default(), &mut |_| Err(limit("job exhausted"))).is_err());
let pass = film
.complete_pass(&CancelToken::default(), &mut |n| {
charged += n;
Ok(())
})
.unwrap();
assert_eq!(charged, 4);
assert_eq!(pass.samples_per_pixel(), 1);
}
#[test]
fn sample_counts_reach_4096_and_the_declared_cap_without_truncation() {
for samples in [4096, MAX_SAMPLES] {
let mut film = film(1, 1, samples);
let cancel = CancelToken::default();
let mut charged = 0u64;
for _ in 0..samples {
film.add_range(0, &[[7.0, 3.0, 1.0]], &cancel, &mut |n| {
charged += n;
Ok(())
})
.unwrap();
}
assert_eq!(charged, u64::from(samples) * 2);
let expected = PixelStatistics { mean: [7.0, 3.0, 1.0], variance: [0.0; 3], count: samples };
assert_eq!(film.pixel_statistics(0).unwrap(), expected);
assert_eq!(film.complete_pass(&cancel, &mut |_| Ok(())).unwrap().samples_per_pixel(), samples);
assert_eq!(film.add_range(0, &[[1.0; 3]], &cancel, &mut |_| Ok(())).unwrap_err().code, ErrorCode::LimitExceeded);
assert_eq!(film.pixel_statistics(0).unwrap(), expected);
}
}
#[test]
fn construction_stops_on_exhausted_caller_work() {
let bytes = FilmAccumulator::required_bytes(4, 1, 1).unwrap();
let mut charged = 0u64;
let result = FilmAccumulator::new(4, 1, 1, bytes, &CancelToken::default(), &mut |n| {
charged += n;
if charged > 2 { Err(limit("existing job budget exhausted")) } else { Ok(()) }
});
assert!(matches!(result, Err(JobError { code: ErrorCode::LimitExceeded, .. })));
assert_eq!(charged, 3);
}
+129 -42
View File
@@ -1,4 +1,4 @@
//! Caller-owned CPU execution host. One worker, two bounded queued inputs; browser pumping
//! Caller-owned image execution host. One worker, two bounded queued inputs; browser pumping
//! uses the same incremental task without threads. Shared ownership never includes CAD state.
use std::collections::VecDeque;
@@ -12,8 +12,8 @@ use std::sync::{
use serde::Serialize;
use super::{
Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageRequest, ImageTask, JobError, JobId, Phase, Progress, Result,
TaskPoll, admit,
Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageExecutor, ImageRequest, ImageTask, ImageWork, JobError,
JobId, JobOptions, Phase, Progress, Result, TaskPoll, admit,
};
use crate::{
Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFormat, RenderLimits, RenderMode, RenderOperation, RenderProfile, ScratchLayout,
@@ -48,7 +48,7 @@ impl Budget {
let next = used
.checked_add(bytes)
.filter(|n| *n <= MEMORY_LIMIT)
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU image host's 512 MiB admission pool is full"))?;
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "image host's 512 MiB admission pool is full"))?;
match self.used.compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => break,
Err(current) => used = current,
@@ -90,7 +90,10 @@ pub struct JobStatus {
pub job: JobId,
pub scene_generation: u64,
pub request: ImageRequest,
pub environment: Option<crate::lighting::environment::EnvironmentInfo>,
pub resolved: crate::ResolvedRequest,
pub options: JobOptions,
pub textures: Option<super::textures::TextureSummary>,
pub progress: Progress,
pub error: Option<JobError>,
pub frame: Option<FrameInfo>,
@@ -102,11 +105,14 @@ struct Data {
frame: Option<Arc<RenderFrame>>,
}
struct Record {
textures: Option<super::textures::TextureSummary>,
id: JobId,
scene_generation: u64,
request: ImageRequest,
environment: Option<crate::lighting::environment::EnvironmentInfo>,
resolved: crate::ResolvedRequest,
cancel: CancelToken,
options: JobOptions,
data: Mutex<Data>,
#[cfg(not(target_arch = "wasm32"))]
done: Condvar,
@@ -132,7 +138,10 @@ impl JobHandle {
job: self.0.id,
scene_generation: self.0.scene_generation,
request: self.0.request.clone(),
environment: self.0.environment.clone(),
resolved: self.0.resolved.clone(),
options: self.0.options,
textures: self.0.textures.clone(),
progress: data.progress,
error: data.error.clone(),
frame: data.frame.as_ref().map(|f| f.info.clone()),
@@ -176,12 +185,16 @@ impl JobHandle {
}
struct Work {
request: ImageRequest,
executor: Arc<dyn ImageExecutor>,
scene: ProductScene,
handle: JobHandle,
reservation: Reservation,
}
struct Running {
task: ImageTask,
/// Taken and dropped as the frame completes, before the reservation shrinks to the PNG: the
/// task owns its scratch (pixels, device buffers), which the full reservation pays for.
task: Option<Box<dyn ImageWork>>,
handle: JobHandle,
reservation: Option<Reservation>,
}
@@ -214,9 +227,12 @@ impl Running {
{
data.progress.phase = Phase::Preparing;
}
let task = ImageTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone());
let task = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
work.executor.begin(work.scene, work.request, work.handle.0.options, work.handle.0.cancel.clone())
}))
.unwrap_or_else(|_| Err(JobError::new(ErrorCode::Internal, "image executor failed while preparing the job")));
match task {
Ok(task) => Some(Self { task, handle: work.handle, reservation: Some(work.reservation) }),
Ok(task) => Some(Self { task: Some(task), handle: work.handle, reservation: Some(work.reservation) }),
Err(e) => {
terminal(&work.handle, e);
None
@@ -225,7 +241,8 @@ impl Running {
}
fn advance(&mut self, units: usize) -> Result<bool> {
let result = self.task.step(units)?;
let task = self.task.as_mut().ok_or_else(|| JobError::new(ErrorCode::Internal, "render task already finished"))?;
let result = task.step(units)?;
let mut data = self.handle.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
// The final publication check is under the same lock as cancellation.
if self.handle.0.cancel.is_cancelled() {
@@ -240,6 +257,8 @@ impl Running {
Ok(true)
}
TaskPoll::Complete(png) => {
// The task's scratch goes before its charge does (still under the publication lock).
drop(self.task.take());
let mut reservation = self.reservation.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing render reservation"))?;
reservation.shrink(png.capacity() as u64 + 1024)?;
let info = FrameInfo {
@@ -269,7 +288,7 @@ impl Running {
false
}
Err(_) => {
terminal(&self.handle, JobError::new(ErrorCode::Internal, "CPU image host encountered an internal error"));
terminal(&self.handle, JobError::new(ErrorCode::Internal, "image host encountered an internal error"));
false
}
}
@@ -286,6 +305,7 @@ struct Shared {
}
struct Owner {
executor: Arc<dyn ImageExecutor>,
shared: Arc<Shared>,
kind: HostKind,
running: Mutex<Option<Running>>,
@@ -320,9 +340,12 @@ impl Drop for Owner {
}
#[derive(Clone)]
pub struct CpuHost(Arc<Owner>);
pub struct ImageHost(Arc<Owner>);
impl CpuHost {
/// Backwards-compatible CPU host constructors. Additional executors use `ImageHost::with_executor`.
pub type CpuHost = ImageHost;
impl ImageHost {
fn shared() -> Arc<Shared> {
Arc::new(Shared {
queue: Mutex::new(VecDeque::new()),
@@ -335,7 +358,13 @@ impl CpuHost {
}
pub fn cooperative() -> Self {
Self::cooperative_with(Arc::new(CpuExecutor))
}
/// A cooperative host over `executor` (pumped by its caller).
fn cooperative_with(executor: Arc<dyn ImageExecutor>) -> Self {
Self(Arc::new(Owner {
executor,
shared: Self::shared(),
kind: HostKind::Cooperative,
running: Mutex::new(None),
@@ -351,16 +380,31 @@ impl CpuHost {
}
#[cfg(not(target_arch = "wasm32"))]
{
let shared = Self::shared();
let worker_shared = shared.clone();
let worker = std::thread::Builder::new()
.name("solidcraft-render".into())
.spawn(move || native_worker(worker_shared))
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start CPU image worker: {e}")))?;
Ok(Self(Arc::new(Owner { shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) })))
Self::with_executor(Arc::new(CpuExecutor))
}
}
/// Start an isolated bounded native worker. GPU discovery occurs before registration.
#[cfg(not(target_arch = "wasm32"))]
pub fn with_executor(executor: Arc<dyn ImageExecutor>) -> Result<Self> {
let descriptor = executor.descriptor()?;
if descriptor.profiles.len() != 1
|| descriptor
.profiles
.iter()
.any(|p| p.mode != RenderMode::Preview || p.operation != RenderOperation::Image || p.formats != [RenderFormat::Png])
{
return Err(JobError::new(ErrorCode::InvalidRequest, "image worker currently accepts one Preview/Image/PNG profile"));
}
let shared = Self::shared();
let worker_shared = shared.clone();
let worker = std::thread::Builder::new()
.name("solidcraft-render".into())
.spawn(move || native_worker(worker_shared))
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start image worker: {e}")))?;
Ok(Self(Arc::new(Owner { executor, shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) })))
}
pub fn kind(&self) -> HostKind {
self.0.kind
}
@@ -376,31 +420,38 @@ impl CpuHost {
}
pub fn descriptor(&self) -> Result<ExecutorDescriptor> {
Ok(ExecutorDescriptor {
id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?,
availability: if self.0.shared.stop.load(Ordering::Acquire) {
Availability::Unavailable { reason: "CPU image host has stopped".into() }
} else {
Availability::Available
},
profiles: vec![RenderProfile {
mode: RenderMode::Preview,
operation: RenderOperation::Image,
formats: vec![RenderFormat::Png],
features: vec![crate::RenderFeature::Pbr],
}],
limits: RenderLimits::default(),
scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 },
})
let mut descriptor = self.0.executor.descriptor()?;
if !self.is_available() {
descriptor.availability = Availability::Unavailable { reason: "image host has stopped".into() };
}
Ok(descriptor)
}
pub fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
options.validate()?;
self.0.executor.admit(scene, request, options)
}
pub fn submit(&self, id: JobId, scene_generation: u64, scene: ProductScene, request: ImageRequest) -> Result<JobHandle> {
self.submit_with_options(id, scene_generation, scene, request, JobOptions::default())
}
pub fn submit_with_options(
&self,
id: JobId,
scene_generation: u64,
scene: ProductScene,
request: ImageRequest,
options: JobOptions,
) -> Result<JobHandle> {
if id.session == 0 || id.serial == 0 || id.generation == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid render job identity"));
}
let Admission { estimated_total_bytes, .. } = admit(&scene, &request)?;
let Admission { estimated_total_bytes, .. } = self.admit(&scene, &request, options)?;
let mut backend = crate::builtin_backend();
backend.executors.push(self.descriptor()?);
let descriptor = self.descriptor()?;
let executor = descriptor.id.clone();
backend.executors = vec![descriptor];
let resolved = crate::resolve_request(
&[backend],
&crate::RenderRequest {
@@ -415,29 +466,37 @@ impl CpuHost {
},
)
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, e.to_string()))?;
if resolved.executor.as_str() != "cpu-product" {
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU product executor is unavailable"));
if resolved.executor != executor {
return Err(JobError::new(ErrorCode::HostUnavailable, "image executor is unavailable"));
}
let mut queue = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?;
if self.0.shared.stop.load(Ordering::Acquire) {
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU image host has stopped"));
return Err(JobError::new(ErrorCode::HostUnavailable, "image host has stopped"));
}
queue.retain(|work| !work.handle.0.cancel.is_cancelled());
if queue.len() >= QUEUE_LIMIT {
return Err(JobError::new(ErrorCode::QueueFull, "CPU image queue is full"));
return Err(JobError::new(ErrorCode::QueueFull, "image queue is full"));
}
let reservation = self.0.shared.budget.reserve(estimated_total_bytes)?;
// Keep large captured sources in admitted work only. Status/retained frames
// must not keep resource bytes alive after the reservation is shrunk.
let environment = request.environment.as_ref().map(|source| source.info().clone());
let public_request = ImageRequest { textures: None, environment: None, ..request.clone() };
let textures = request.textures.as_ref().map(|t| t.summary());
let handle = JobHandle(Arc::new(Record {
textures,
id,
scene_generation,
request,
request: public_request,
environment,
resolved,
cancel: CancelToken::default(),
options,
data: Mutex::new(Data { progress: Progress { phase: Phase::Queued, done: 0, total: 1 }, error: None, frame: None }),
#[cfg(not(target_arch = "wasm32"))]
done: Condvar::new(),
}));
queue.push_back(Work { scene, handle: handle.clone(), reservation });
queue.push_back(Work { request, executor: self.0.executor.clone(), scene, handle: handle.clone(), reservation });
drop(queue);
#[cfg(not(target_arch = "wasm32"))]
self.0.shared.wake.notify_one();
@@ -514,3 +573,31 @@ fn native_worker(shared: Arc<Shared>) {
}
}
}
struct CpuExecutor;
impl ImageExecutor for CpuExecutor {
fn descriptor(&self) -> Result<ExecutorDescriptor> {
Ok(ExecutorDescriptor {
id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?,
availability: Availability::Available,
profiles: vec![RenderProfile {
mode: RenderMode::Preview,
operation: RenderOperation::Image,
formats: vec![RenderFormat::Png],
features: vec![crate::RenderFeature::Pbr],
}],
limits: RenderLimits::default(),
scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 },
})
}
fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
if options != JobOptions::default() {
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU product does not support sampling or exposure controls"));
}
admit(scene, request)
}
fn begin(&self, scene: ProductScene, request: ImageRequest, _options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
Ok(Box::new(ImageTask::new(scene, request, cancel)?))
}
}
+199 -1
View File
@@ -2,7 +2,15 @@ use super::*;
use solidcraft_geom::{Aabb3, Mesh};
fn request() -> ImageRequest {
ImageRequest { camera: Default::default(), width: 16, height: 16, background: [40, 80, 120], transparent_background: false }
ImageRequest {
textures: None,
environment: None,
camera: Default::default(),
width: 16,
height: 16,
background: [40, 80, 120],
transparent_background: false,
}
}
fn empty() -> ProductScene {
ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY }
@@ -77,6 +85,7 @@ fn malformed_preparation_fails_the_job_without_stopping_the_host() {
color: [0; 3],
opacity: 1.0,
surface: None,
transmission: None,
faces: Vec::new(),
}],
};
@@ -129,3 +138,192 @@ fn native_job_finishes_without_any_poll_or_ui_pump() {
assert_eq!(status.progress.phase, Phase::Completed);
assert_eq!(job.frame().unwrap().unwrap().info.scene_generation, 12);
}
#[test]
fn environment_payload_is_owned_by_admitted_work_not_retained_status() {
struct ResourceExecutor;
impl ImageExecutor for ResourceExecutor {
fn descriptor(&self) -> Result<ExecutorDescriptor> {
CpuExecutor.descriptor()
}
fn admit(&self, scene: &ProductScene, request: &ImageRequest, _: JobOptions) -> Result<Admission> {
let mut admission = super::super::admit_geometry(scene, request, 262_144)?;
admission.estimated_total_bytes += request.environment.as_ref().map_or(0, |source| source.admitted_bytes());
Ok(admission)
}
fn begin(&self, scene: ProductScene, mut request: ImageRequest, _: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
request.environment = None;
Ok(Box::new(ImageTask::new(scene, request, cancel)?))
}
}
for cancelled in [false, true] {
let host = ImageHost(Arc::new(Owner {
executor: Arc::new(ResourceExecutor),
shared: ImageHost::shared(),
kind: HostKind::Cooperative,
running: Mutex::new(None),
#[cfg(not(target_arch = "wasm32"))]
_worker: None,
}));
let source = Arc::new(crate::lighting::environment::EnvironmentSource::sky(Default::default(), 0.0, 1.0).unwrap());
let weak = Arc::downgrade(&source);
let mut input = request();
input.environment = Some(source);
let job = host.submit(id(1, 1), 1, empty(), input).unwrap();
let status = job.status().unwrap();
assert!(status.request.environment.is_none());
assert_eq!(status.environment.as_ref().unwrap().kind, "original_gradient_sky");
assert!(weak.upgrade().is_some(), "queuedwork owns source");
if cancelled {
job.cancel().unwrap();
}
drain(&host);
assert!(weak.upgrade().is_none(), "completed/cancelled work releases captured source despite retained status/job");
assert_eq!(job.status().unwrap().progress.phase, if cancelled { Phase::Cancelled } else { Phase::Completed });
}
}
#[test]
fn texture_payload_is_owned_by_admitted_work_not_retained_status() {
struct ResourceExecutor;
impl ImageExecutor for ResourceExecutor {
fn descriptor(&self) -> Result<ExecutorDescriptor> {
CpuExecutor.descriptor()
}
fn admit(&self, scene: &ProductScene, request: &ImageRequest, _: JobOptions) -> Result<Admission> {
let mut admission = super::super::admit_geometry(scene, request, 262_144)?;
admission.estimated_total_bytes += request.textures.as_ref().map_or(0, |source| source.working_bytes());
Ok(admission)
}
fn begin(&self, scene: ProductScene, mut request: ImageRequest, _: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
request.textures = None;
Ok(Box::new(ImageTask::new(scene, request, cancel)?))
}
}
for cancelled in [false, true] {
let host = ImageHost(Arc::new(Owner {
executor: Arc::new(ResourceExecutor),
shared: ImageHost::shared(),
kind: HostKind::Cooperative,
running: Mutex::new(None),
#[cfg(not(target_arch = "wasm32"))]
_worker: None,
}));
use crate::job::textures::{PlanarFrame, RuntimeTextures, TextureBinding, TextureMap};
use image::ImageEncoder;
let mut bytes = Vec::new();
image::codecs::png::PngEncoder::new(&mut bytes).write_image(&[128, 128, 255, 255], 1, 1, image::ExtendedColorType::Rgba8).unwrap();
let source = Arc::new(
RuntimeTextures::new(vec![TextureBinding {
body_index: 0,
body_name: "unused test resource".into(),
frame: PlanarFrame { origin: [0.; 3], u: [1., 0., 0.], v: [0., 1., 0.] },
normal_strength: 1.,
base_color: Some(TextureMap::new(bytes, crate::texture_input::MapRole::BaseColor).unwrap()),
normal: None,
}])
.unwrap(),
);
let weak = Arc::downgrade(&source);
let mut input = request();
input.textures = Some(source);
let job = host.submit(id(1, 1), 1, empty(), input).unwrap();
let status = job.status().unwrap();
assert!(status.request.textures.is_none());
assert_eq!(status.textures.as_ref().unwrap().bodies, 1);
assert!(weak.upgrade().is_some(), "queuedwork owns source");
if cancelled {
job.cancel().unwrap();
}
drain(&host);
assert!(weak.upgrade().is_none(), "completed/cancelled work releases captured source despite retained status/job");
assert_eq!(job.status().unwrap().progress.phase, if cancelled { Phase::Cancelled } else { Phase::Completed });
}
}
/// An executor whose task reports, as it is dropped, how much of the pool is admitted, and
/// completes after `steps` steps (or never, cancelled on its last one with `cancel_last`).
struct DropProbe {
admitted_at_drop: Arc<AtomicU64>,
budget: Arc<std::sync::OnceLock<Arc<Budget>>>,
estimated: u64,
}
struct ProbeTask {
admitted_at_drop: Arc<AtomicU64>,
budget: Arc<std::sync::OnceLock<Arc<Budget>>>,
steps: u32,
}
impl Drop for ProbeTask {
fn drop(&mut self) {
let used = self.budget.get().map_or(u64::MAX, |b| b.used.load(Ordering::Acquire));
self.admitted_at_drop.store(used, Ordering::Release);
}
}
impl ImageWork for ProbeTask {
fn step(&mut self, _units: usize) -> Result<TaskPoll> {
self.steps += 1;
if self.steps < 3 {
return Ok(TaskPoll::Pending(Progress { phase: Phase::Running, done: u64::from(self.steps), total: 3 }));
}
Ok(TaskPoll::Complete(vec![0u8; 100]))
}
}
impl ImageExecutor for DropProbe {
fn descriptor(&self) -> Result<ExecutorDescriptor> {
CpuExecutor.descriptor()
}
fn admit(&self, _: &ProductScene, _: &ImageRequest, _: JobOptions) -> Result<Admission> {
Ok(Admission {
triangles: 0,
face_appearances: 0,
geometry_bytes: 0,
pixel_scratch_bytes: self.estimated,
preparation_bytes: 0,
estimated_total_bytes: self.estimated,
encoded_limit: 4096,
})
}
fn begin(&self, _: ProductScene, _: ImageRequest, _: JobOptions, _: CancelToken) -> Result<Box<dyn ImageWork>> {
Ok(Box::new(ProbeTask { admitted_at_drop: self.admitted_at_drop.clone(), budget: self.budget.clone(), steps: 0 }))
}
}
fn probe_host(estimated: u64) -> (CpuHost, Arc<AtomicU64>) {
let admitted_at_drop = Arc::new(AtomicU64::new(0));
let budget = Arc::new(std::sync::OnceLock::new());
let host = CpuHost::cooperative_with(Arc::new(DropProbe { admitted_at_drop: admitted_at_drop.clone(), budget: budget.clone(), estimated }));
let _ = budget.set(host.0.shared.budget.clone());
(host, admitted_at_drop)
}
#[test]
fn a_completed_task_is_dropped_while_its_full_reservation_still_pays_for_it() {
let estimated = 1 << 20;
let (host, admitted_at_drop) = probe_host(estimated);
let job = host.submit(id(1, 1), 1, empty(), request()).unwrap();
drain(&host);
assert_eq!(job.status().unwrap().progress.phase, Phase::Completed);
// While the task (and its scratch) was being dropped, its whole charge was still held.
assert_eq!(admitted_at_drop.load(Ordering::Acquire), estimated);
// Afterwards only the PNG's lease remains, and the frame keeps it.
let frame = job.frame().unwrap().unwrap();
assert!(host.admitted_bytes() > 0 && host.admitted_bytes() < estimated, "{}", host.admitted_bytes());
drop(job);
drop(frame);
assert_eq!(host.admitted_bytes(), 0);
}
#[test]
fn a_cancel_on_the_final_step_publishes_no_frame_and_drops_the_task() {
let estimated = 1 << 20;
let (host, admitted_at_drop) = probe_host(estimated);
let job = host.submit(id(1, 1), 1, empty(), request()).unwrap();
// Two pending steps, then cancel before the step that would complete.
host.pump(1).unwrap();
host.pump(1).unwrap();
job.cancel().unwrap();
drain(&host);
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
assert!(job.frame().unwrap().is_none());
assert_eq!(admitted_at_drop.load(Ordering::Acquire), estimated, "the task went before its charge");
assert_eq!(host.admitted_bytes(), 0);
}
+66 -19
View File
@@ -49,7 +49,35 @@ fn add_bytes(sum: &mut u64, count: usize, size: usize) -> Result<()> {
/// Conservative admission before allocating pixels or projected-work lists. Source Arcs are
/// counted even when another owner already holds them. Allocator and codec internals are estimates.
/// Includes the CPU raster work estimate and its 384 MiB per-job check.
pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
if request.environment.is_some() {
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU product does not support environment maps"));
}
if request.textures.is_some() {
return Err(JobError::new(ErrorCode::InvalidRequest, "runtime textures require the progressive GPU executor"));
}
admit_raster(admit_source(scene, request, MAX_TRIANGLES)?)
}
/// Common camera, dimension and source admission for registered executors, without any CPU
/// raster estimate: the returned admission counts retained source geometry only
/// (`pixel_scratch_bytes` and `preparation_bytes` are zero). Executors budget their own
/// preparation and pixel storage, apply their own job limit, and validate triangle contents
/// before uploading anything to a device.
pub fn admit_geometry(scene: &ProductScene, request: &ImageRequest, max_triangles: u64) -> Result<Admission> {
Ok(admit_source(scene, request, max_triangles)?.admission)
}
/// A validated source with its geometry accounting, and the builtin raster scratch estimate
/// for the requested dimensions (used only by the CPU estimate).
struct Source {
admission: Admission,
bodies: u64,
raster_scratch_bytes: u64,
}
fn admit_source(scene: &ProductScene, request: &ImageRequest, max_triangles: u64) -> Result<Source> {
if !request.camera.is_valid() {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
}
@@ -77,8 +105,8 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission>
triangles = triangles.checked_add(m.triangles.len() as u64).ok_or_else(|| limit("triangle count overflow"))?;
faces = faces.checked_add(body.faces.len() as u64).ok_or_else(|| limit("appearance count overflow"))?;
polylines = polylines.checked_add(m.edges.len() as u64 + m.edge_faces.len() as u64).ok_or_else(|| limit("edge count overflow"))?;
if triangles > MAX_TRIANGLES || faces > MAX_TRIANGLES || polylines > MAX_TRIANGLES {
return Err(limit("product image exceeds the 262144 triangle/appearance/polyline admission cap"));
if triangles > max_triangles || faces > max_triangles || polylines > max_triangles {
return Err(limit("product image exceeds its triangle/appearance/polyline admission cap"));
}
add_bytes(&mut geometry, m.positions.capacity(), std::mem::size_of::<Vec3>())?;
add_bytes(&mut geometry, m.normals.capacity(), std::mem::size_of::<Vec3>())?;
@@ -97,36 +125,51 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission>
for count in [m.seams.capacity(), m.smooth_edges.capacity(), m.sharp_edges.capacity()] {
add_bytes(&mut geometry, count, 1)?;
}
if body.surface.is_some() && body.opacity != 1.0 {
if body.surface.is_some() && body.transmission.is_none() && body.opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
if !body.opacity.is_finite() || !(0.0..=1.0).contains(&body.opacity) {
return Err(invalid("invalid body opacity"));
}
}
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
Ok(Source {
admission: Admission {
triangles,
face_appearances: faces,
geometry_bytes: geometry,
pixel_scratch_bytes: 0,
preparation_bytes: 0,
estimated_total_bytes: geometry,
encoded_limit,
},
bodies: scene.meshes.len() as u64,
raster_scratch_bytes: resolved.estimated_scratch_bytes,
})
}
/// The CPU raster scratch/preparation estimate and its 384 MiB per-job check.
fn admit_raster(source: Source) -> Result<Admission> {
let mut a = source.admission;
// Three bounded 16-byte lists, a conservative map-node allowance, and codec overhead.
let preparation = triangles
let preparation = a
.triangles
.checked_mul(48)
.and_then(|n| faces.checked_mul(160).and_then(|f| n.checked_add(f)))
.and_then(|n| n.checked_add(scene.meshes.len() as u64 * 128 + 262_144))
.and_then(|n| a.face_appearances.checked_mul(160).and_then(|f| n.checked_add(f)))
.and_then(|n| n.checked_add(source.bodies * 128 + 262_144))
.ok_or_else(|| limit("work list size overflow"))?;
let total = geometry
let total = a
.geometry_bytes
.checked_add(preparation)
.and_then(|n| n.checked_add(resolved.estimated_scratch_bytes))
.and_then(|n| n.checked_add(source.raster_scratch_bytes))
.ok_or_else(|| limit("image memory overflow"))?;
if total > MAX_JOB_BYTES {
return Err(limit("product job exceeds its 384 MiB admission estimate"));
}
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
Ok(Admission {
triangles,
face_appearances: faces,
geometry_bytes: geometry,
pixel_scratch_bytes: resolved.estimated_scratch_bytes,
preparation_bytes: preparation,
estimated_total_bytes: total,
encoded_limit,
})
a.pixel_scratch_bytes = source.raster_scratch_bytes;
a.preparation_bytes = preparation;
a.estimated_total_bytes = total;
Ok(a)
}
fn reserve<T>(count: usize) -> Result<Vec<T>> {
@@ -380,7 +423,11 @@ impl ImageTask {
return Err(invalid("non-finite product triangle"));
}
let look = self.looks.get(draw.body as usize).and_then(|looks| mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface), |look| (look.color, look.opacity, look.surface));
// Transmission is physical optical data, never a request to change CPU preview.
// Keep the existing scalar sorted-alpha shading for glass previews.
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |look| {
(look.color, look.opacity, look.surface.filter(|_| look.transmission.is_none()))
});
if surface.is_some() && opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
+36 -3
View File
@@ -5,7 +5,7 @@ use solidcraft_geom::{Aabb3, Mesh};
fn request() -> ImageRequest {
let mut camera = Camera { distance: 50.0, ..Default::default() };
camera.set_view(StandardView::Front);
ImageRequest { camera, width: 64, height: 64, background: [20, 40, 60], transparent_background: true }
ImageRequest { textures: None, environment: None, camera, width: 64, height: 64, background: [20, 40, 60], transparent_background: true }
}
fn square(name: &str, colour: [u8; 3], opacity: f64, offset: Vec3) -> ProductMesh {
@@ -21,7 +21,7 @@ fn square(name: &str, colour: [u8; 3], opacity: f64, offset: Vec3) -> ProductMes
tri_face: vec![0, 1],
..Default::default()
};
ProductMesh { name: name.into(), mesh: Arc::new(mesh), color: colour, opacity, surface: None, faces: Vec::new() }
ProductMesh { name: name.into(), mesh: Arc::new(mesh), color: colour, opacity, surface: None, transmission: None, faces: Vec::new() }
}
fn scene(meshes: Vec<ProductMesh>) -> ProductScene {
@@ -68,6 +68,17 @@ fn glass_is_sorted_back_to_front_independently_of_body_order() {
assert!(p[2] > p[0] + 30, "the near blue layer contributes more: {p:?}");
}
#[test]
fn physical_transmission_keeps_the_exact_scalar_cpu_preview() {
let original = square("Glass", [80, 180, 140], 0.25, Vec3::ZERO);
let mut glass = original.clone();
glass.surface = Some(crate::surface::OpaqueSurface::new(Vec3::new(0.2, 0.4, 0.3), 0.0, 0.1, 1.5).unwrap());
glass.transmission = Some(crate::transmission::Transmission::new(Vec3::new(-0.7_f64.ln(), -0.9_f64.ln(), 0.0) / 10.0, None).unwrap());
let expected = finish(ImageTask::new(scene(vec![original]), request(), CancelToken::default()).unwrap());
let actual = finish(ImageTask::new(scene(vec![glass]), request(), CancelToken::default()).unwrap());
assert_eq!(actual, expected);
}
#[test]
fn cancellation_is_observed_during_preparation_sort_pixels_and_encoding() {
for stage in [Stage::Looks, Stage::Prepare, Stage::Sort, Stage::Clear, Stage::Glass, Stage::Downsample, Stage::Encode] {
@@ -116,7 +127,7 @@ fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() {
if bad == 0 {
Arc::make_mut(&mut body.mesh).triangles[0][0] = u32::MAX;
} else {
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None });
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None, transmission: None });
}
let mut task = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).unwrap();
let error = loop {
@@ -145,3 +156,25 @@ fn png_output_budget_and_cancellation_fail_without_partial_publication() {
assert!(writer.flush().is_err());
assert_eq!(*bytes.lock().unwrap(), b"PNG");
}
#[test]
fn registered_executor_geometry_admission_excludes_the_cpu_raster_estimate() {
// A library-bounded source: about 100 MiB of face appearances on a small mesh at the
// largest image. Its CPU raster estimate is above 384 MiB at a registered executor's cap.
let count = (100 * 1024 * 1024 / std::mem::size_of::<FaceAppearance>()).min(1_000_000);
let mut body = square("appearances", [200; 3], 1.0, Vec3::ZERO);
body.faces = vec![FaceAppearance { face: 0, color: [200; 3], opacity: 1.0, surface: None, transmission: None }; count];
let source = scene(vec![body]);
let mut r = request();
r.width = 2048;
r.height = 2048;
let cpu = admit_raster(admit_source(&source, &r, 1_048_576).unwrap()).unwrap_err();
assert_eq!(cpu.code, ErrorCode::LimitExceeded);
assert!(cpu.message.contains("384 MiB"), "{}", cpu.message);
let geometry = admit_geometry(&source, &r, 1_048_576).unwrap();
assert_eq!((geometry.pixel_scratch_bytes, geometry.preparation_bytes), (0, 0));
assert_eq!(geometry.estimated_total_bytes, geometry.geometry_bytes);
assert_eq!(geometry.face_appearances, count as u64);
// The CPU host itself keeps rejecting the source.
assert_eq!(admit(&source, &r).unwrap_err().code, ErrorCode::LimitExceeded);
}
+43
View File
@@ -0,0 +1,43 @@
//! Runtime thin-lens camera settings. Focus is an axial distance from the eye,
//! not distance along an off-axis ray. No document or camera-navigation changes.
use super::{ErrorCode, JobError, Result};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ThinLens {
pub aperture_radius_mm: f64,
pub focus_distance_mm: f64,
}
impl ThinLens {
pub fn validate(self) -> Result<()> {
if !self.aperture_radius_mm.is_finite()
|| !(0.0..=1e7).contains(&self.aperture_radius_mm)
|| !self.focus_distance_mm.is_finite()
|| !(1e-6..=1e9).contains(&self.focus_distance_mm)
{
return Err(JobError::new(
ErrorCode::InvalidRequest,
"lens aperture radius must be 0..10000000 mm and axial focus distance 0.000001..1000000000 mm",
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lens_admission_rejects_nonfinite_and_unbounded_values() {
for aperture in [f64::NAN, f64::INFINITY, -1.0, 1e7 + 1.0] {
assert!(ThinLens { aperture_radius_mm: aperture, focus_distance_mm: 10.0 }.validate().is_err());
}
for focus in [f64::NAN, f64::INFINITY, 0.0, -1.0, 1e9 + 1.0] {
assert!(ThinLens { aperture_radius_mm: 1.0, focus_distance_mm: focus }.validate().is_err());
}
assert!(ThinLens { aperture_radius_mm: 0.0, focus_distance_mm: 1.0 }.validate().is_ok());
assert!(serde_json::from_str::<ThinLens>(r#"{"aperture_radius_mm":1,"focus_distance_mm":10,"f_stop":2}"#).is_err());
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Runtime rectangular emitters and constant environment radiance. Coordinates are
//! world-space millimetres; radiance is linear RGB. No saved-document contract.
use super::{ErrorCode, JobError, Result};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
pub const MAX_AREA_LIGHTS: usize = 4;
/// A one-sided rectangle: `center + u*s + v*t`, with s,t in [-1,1].
/// Its emitting normal is `u cross v`; u and v are orthogonal half-edges.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AreaLight {
pub center: [f64; 3],
pub u: [f64; 3],
pub v: [f64; 3],
pub radiance: [f64; 3],
}
fn vector(a: [f64; 3]) -> Vec3 {
let [x, y, z] = a;
Vec3::new(x, y, z)
}
impl AreaLight {
pub fn validate(self) -> Result<()> {
let u = vector(self.u);
let v = vector(self.v);
let ul = u.len();
let vl = v.len();
if self.center.into_iter().any(|x| !x.is_finite() || x.abs() > 1e12)
|| self.u.into_iter().chain(self.v).any(|x| !x.is_finite())
|| !(1e-4..=1e6).contains(&ul)
|| !(1e-4..=1e6).contains(&vl)
|| u.dot(v).abs() > 1e-6 * ul * vl
|| self.radiance.into_iter().any(|x| !x.is_finite() || !(0.0..=1e6).contains(&x))
{
return Err(JobError::new(
ErrorCode::InvalidRequest,
"area light requires finite center, orthogonal half-edges of 0.0001..1000000 mm and radiance 0..1000000",
));
}
Ok(())
}
pub fn area(self) -> f64 {
4.0 * vector(self.u).cross(vector(self.v)).len()
}
}
/// Uniform-radiance finite angular disk at infinity. Direction points toward the sun.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Sun {
pub direction: [f64; 3],
pub angular_radius_rad: f64,
pub radiance: [f64; 3],
}
impl Sun {
pub fn validate(self) -> Result<()> {
if self.direction.iter().any(|x| !x.is_finite())
|| !(1e-8..=1e8).contains(&vector(self.direction).len())
|| !self.angular_radius_rad.is_finite()
|| !(0.001..=std::f64::consts::FRAC_PI_2).contains(&self.angular_radius_rad)
|| self.radiance.iter().any(|x| !x.is_finite() || !(0.0..=1e8).contains(x))
{
return Err(JobError::new(
ErrorCode::InvalidRequest,
"sun requires finite nonzero direction, radius 0.001..pi/2 radians and radiance 0..100000000",
));
}
Ok(())
}
pub fn solid_angle(self) -> f64 {
4.0 * std::f64::consts::PI * (0.5 * self.angular_radius_rad).sin().powi(2)
}
pub fn direction(self) -> Vec3 {
let d = vector(self.direction);
d / d.len()
}
}
/// A small copyable light list, serialized as an ordinary bounded JSON array.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct AreaLights {
entries: [AreaLight; MAX_AREA_LIGHTS],
len: usize,
}
impl AreaLights {
pub fn new(lights: &[AreaLight]) -> Result<Self> {
if lights.len() > MAX_AREA_LIGHTS {
return Err(JobError::new(ErrorCode::InvalidRequest, "at most four area lights are supported"));
}
let mut result = Self::default();
for (slot, light) in result.entries.iter_mut().zip(lights) {
light.validate()?;
*slot = *light;
}
result.len = lights.len();
Ok(result)
}
pub fn iter(&self) -> impl Iterator<Item = &AreaLight> {
self.entries.iter().take(self.len)
}
}
impl Serialize for AreaLights {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.collect_seq(self.iter())
}
}
impl<'de> Deserialize<'de> for AreaLights {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = AreaLights;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an array of at most four rectangular area lights")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
let mut result = AreaLights::default();
while let Some(light) = seq.next_element::<AreaLight>()? {
let slot =
result.entries.get_mut(result.len).ok_or_else(|| serde::de::Error::custom("at most four area lights are supported"))?;
light.validate().map_err(serde::de::Error::custom)?;
*slot = light;
result.len += 1;
}
Ok(result)
}
}
deserializer.deserialize_seq(Visitor)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StudioLighting {
pub sun: Option<Sun>,
pub environment: [f64; 3],
pub area_lights: AreaLights,
}
impl StudioLighting {
pub fn validate(self) -> Result<()> {
if let Some(sun) = self.sun {
sun.validate()?;
}
if self.environment.into_iter().any(|x| !x.is_finite() || !(0.0..=1e6).contains(&x)) {
return Err(JobError::new(ErrorCode::InvalidRequest, "environment radiance must be finite and 0..1000000"));
}
for light in self.area_lights.iter() {
light.validate()?;
}
Ok(())
}
}
/// Output-only transform, after linear accumulation and exposure, before sRGB.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToneMap {
#[default]
Reinhard,
AcesFitted,
}
impl ToneMap {
pub fn map(self, linear: f64) -> f64 {
let x = linear.max(0.0);
match self {
Self::Reinhard => x / (1.0 + x),
// Narkowicz (2016), published rational approximation to the ACES RRT/ODT.
// This is a per-channel fit, not the full ACES colour-management pipeline.
// https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/
Self::AcesFitted => ((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0.0, 1.0),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn light() -> AreaLight {
AreaLight { center: [0.0, 0.0, 4.0], u: [2.0, 0.0, 0.0], v: [0.0, -3.0, 0.0], radiance: [5.0; 3] }
}
#[test]
fn bounded_lights_validate_geometry_and_round_trip() {
let lights = AreaLights::new(&[light(); 4]).unwrap();
assert_eq!(light().area(), 24.0);
assert_eq!(serde_json::from_str::<AreaLights>(&serde_json::to_string(&lights).unwrap()).unwrap(), lights);
assert!(AreaLights::new(&[light(); 5]).is_err());
assert!(serde_json::from_str::<AreaLights>(&serde_json::to_string(&vec![light(); 5]).unwrap()).is_err());
for bad in [
AreaLight { u: [0.0; 3], ..light() },
AreaLight { v: [1.0, 1.0, 0.0], ..light() },
AreaLight { radiance: [-1.0; 3], ..light() },
AreaLight { center: [f64::NAN; 3], ..light() },
] {
assert!(bad.validate().is_err());
}
assert!(StudioLighting { environment: [f64::INFINITY; 3], ..Default::default() }.validate().is_err());
}
#[test]
fn finite_sun_validates_and_integrates_its_solid_angle() {
let sun = Sun { direction: [0.0, 0.0, 2.0], angular_radius_rad: 0.2, radiance: [3.0; 3] };
sun.validate().unwrap();
assert_eq!(sun.direction(), Vec3::Z);
assert!((sun.solid_angle() - 2.0 * std::f64::consts::PI * (1.0 - 0.2_f64.cos())).abs() < 1e-14);
for radius in [0.0, 0.00001, f64::NAN, 2.0] {
assert!(Sun { angular_radius_rad: radius, ..sun }.validate().is_err());
}
assert!(Sun { direction: [0.0; 3], ..sun }.validate().is_err());
}
#[test]
fn filmic_is_bounded_monotone_and_preserves_black() {
assert_eq!(ToneMap::default(), ToneMap::Reinhard);
assert_eq!(ToneMap::AcesFitted.map(0.0), 0.0);
assert!((ToneMap::AcesFitted.map(1.0) - 0.8037974683544302).abs() < 1e-14);
let mut previous = 0.0;
for i in 0..1000 {
let value = ToneMap::AcesFitted.map(f64::from(i) / 100.0);
assert!((previous..=1.0).contains(&value));
previous = value;
}
}
}
+73 -3
View File
@@ -1,4 +1,4 @@
//! CPU product-image work and its device-independent, session-scoped protocol.
//! Product-image work and its device-independent, session-scoped protocol.
//! Hosts own execution; documents and history never contain these values.
use std::fmt;
@@ -13,8 +13,73 @@ use crate::{BackendId, Camera, ExecutorId};
mod host;
mod image;
pub use host::{CpuHost, HostKind, JobHandle, JobStatus, RenderFrame};
pub use image::{Admission, ImageTask, TaskPoll, admit};
mod lens;
mod lighting;
mod png;
pub mod textures;
pub use host::{CpuHost, HostKind, ImageHost, JobHandle, JobStatus, RenderFrame};
pub use image::{Admission, ImageTask, TaskPoll, admit, admit_geometry};
pub use lens::ThinLens;
pub use lighting::{AreaLight, AreaLights, MAX_AREA_LIGHTS, StudioLighting, Sun, ToneMap};
pub use png::PngTask;
/// Runtime sampling intent, separate from saved documents and device resources.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct JobOptions {
pub samples: u32,
pub max_depth: u32,
pub seed: u32,
pub exposure_ev: f64,
pub lens: Option<ThinLens>,
pub lighting: Option<StudioLighting>,
pub tone_map: ToneMap,
/// Optional final spatial filter. Raw progressive accumulation remains unchanged.
pub denoise: bool,
}
impl Default for JobOptions {
fn default() -> Self {
Self { samples: 64, max_depth: 8, seed: 0, exposure_ev: 0.0, lens: None, lighting: None, tone_map: ToneMap::default(), denoise: false }
}
}
impl JobOptions {
pub fn validate(&self) -> Result<()> {
if let Some(lighting) = self.lighting {
lighting.validate()?;
}
if let Some(lens) = self.lens {
lens.validate()?;
}
if !(1..=4096).contains(&self.samples)
|| !(1..=16).contains(&self.max_depth)
|| !self.exposure_ev.is_finite()
|| !(-32.0..=32.0).contains(&self.exposure_ev)
{
return Err(JobError::new(ErrorCode::InvalidRequest, "samples must be 1..4096, depth 1..16 and exposure -32..32 EV"));
}
Ok(())
}
}
/// One bounded quantum of image work. Implementations check cancellation between quanta.
pub trait ImageWork: Send {
fn step(&mut self, units: usize) -> Result<TaskPoll>;
}
impl ImageWork for ImageTask {
fn step(&mut self, units: usize) -> Result<TaskPoll> {
self.step(units)
}
}
/// Device-independent executor boundary. The host owns admission leases and publication;
/// an executor owns computation only. GPU implementations live above the render crate.
pub trait ImageExecutor: Send + Sync {
fn descriptor(&self) -> Result<crate::ExecutorDescriptor>;
fn admit(&self, scene: &crate::ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission>;
fn begin(&self, scene: crate::ProductScene, request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>>;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -108,11 +173,16 @@ impl CancelToken {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageRequest {
/// Captured runtime resources; never persisted or accepted as serialized pixel arrays.
#[serde(skip)]
pub environment: Option<Arc<crate::lighting::environment::EnvironmentSource>>,
pub camera: Camera,
pub width: u32,
pub height: u32,
pub background: [u8; 3],
pub transparent_background: bool,
#[serde(skip)]
pub textures: Option<Arc<textures::RuntimeTextures>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+81
View File
@@ -0,0 +1,81 @@
//! Bounded PNG encoding shared by native image executors.
use super::{CancelToken, ErrorCode, ImageRequest, ImageWork, JobError, Phase, Progress, Result, TaskPoll};
use std::{
io::Write,
sync::{Arc, Mutex},
};
struct Output {
bytes: Arc<Mutex<Vec<u8>>>,
limit: usize,
cancel: CancelToken,
}
impl Write for Output {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if self.cancel.is_cancelled() {
return Err(std::io::Error::other("render cancelled"));
}
let mut out = self.bytes.lock().map_err(|_| std::io::Error::other("PNG lock poisoned"))?;
if out.len().checked_add(bytes.len()).is_none_or(|n| n > self.limit) {
return Err(std::io::Error::other("PNG byte limit exceeded"));
}
out.try_reserve_exact(bytes.len()).map_err(|_| std::io::Error::other("PNG allocation failed"))?;
out.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// Owns admitted RGBA bytes and yields after at most four scanlines.
pub struct PngTask {
rgba: Vec<u8>,
row: usize,
width: usize,
height: usize,
bytes: Arc<Mutex<Vec<u8>>>,
writer: Option<png::StreamWriter<'static, Output>>,
cancel: CancelToken,
}
impl PngTask {
pub fn new(request: &ImageRequest, rgba: Vec<u8>, cancel: CancelToken) -> Result<Self> {
let pixels = u64::from(request.width) * u64::from(request.height);
if !(1..=4096).contains(&request.width) || !(1..=4096).contains(&request.height) || pixels > 4_194_304 || rgba.len() as u64 != pixels * 4 {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid PNG dimensions or RGBA length"));
}
cancel.check()?;
let bytes = Arc::new(Mutex::new(Vec::new()));
let output = Output { bytes: bytes.clone(), limit: (pixels * 8 + 65_536) as usize, cancel: cancel.clone() };
let mut encoder = png::Encoder::new(output, request.width, request.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
let writer = encoder.write_header().and_then(|w| w.into_stream_writer()).map_err(encoding)?;
Ok(Self { rgba, row: 0, width: request.width as usize, height: request.height as usize, bytes, writer: Some(writer), cancel })
}
}
fn encoding(e: impl std::fmt::Display) -> JobError {
JobError::new(ErrorCode::EncodingFailed, e.to_string())
}
impl ImageWork for PngTask {
fn step(&mut self, _units: usize) -> Result<TaskPoll> {
self.cancel.check()?;
for _ in 0..4 {
if self.row == self.height {
break;
}
let start = self.row * self.width * 4;
let row = self.rgba.get(start..start + self.width * 4).ok_or_else(|| encoding("missing PNG row"))?;
self.writer.as_mut().ok_or_else(|| encoding("PNG already completed"))?.write_all(row).map_err(encoding)?;
self.row += 1;
}
if self.row != self.height {
return Ok(TaskPoll::Pending(Progress { phase: Phase::Encoding, done: self.row as u64, total: self.height as u64 }));
}
self.writer.take().ok_or_else(|| encoding("PNG already completed"))?.finish().map_err(encoding)?;
self.cancel.check()?;
let mut bytes = self.bytes.lock().map_err(|_| encoding("PNG lock poisoned"))?;
Ok(TaskPoll::Complete(std::mem::take(&mut *bytes)))
}
}
+302
View File
@@ -0,0 +1,302 @@
//! Immutable request-only planar opaque textures. Codecs run on the image worker.
use super::{CancelToken, ErrorCode, JobError, Result};
use crate::{
ProductScene,
surface::{srgb8_to_linear, unit_direction},
texture_input::{MapRole, TextureSource},
texture_sampling::TextureView,
};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
pub const MAX_TEXTURE_BINDINGS: usize = 16;
pub const MAX_TEXTURE_PIXELS_BYTES: u64 = 32 * 1024 * 1024;
pub const MAX_TEXTURE_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
pub const MAX_PLANAR_REPEAT: f64 = 128.0;
fn invalid(message: impl Into<String>) -> JobError {
JobError::new(ErrorCode::InvalidRequest, message)
}
fn v(a: [f64; 3]) -> Vec3 {
Vec3::new(a[0], a[1], a[2])
}
/// `u` and `v` span one full repeat in world-space millimetres. Image v increases
/// downward; normal-map green points along the caller's positive v direction.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanarFrame {
pub origin: [f64; 3],
pub u: [f64; 3],
pub v: [f64; 3],
}
impl PlanarFrame {
pub fn validate(self) -> Result<()> {
let u = v(self.u);
let vv = v(self.v);
let ul = u.len();
let vl = vv.len();
if self.origin.into_iter().any(|c| !c.is_finite() || c.abs() > 1e12)
|| !u.is_finite()
|| !vv.is_finite()
|| !(0.001..=1e6).contains(&ul)
|| !(0.001..=1e6).contains(&vl)
|| u.dot(vv).abs() > 1e-6 * ul * vl
{
return Err(invalid("planar texture frame requires finite origin and orthogonal u/v repeat vectors of 0.001..1000000 mm"));
}
Ok(())
}
pub fn uv(self, point: Vec3) -> Result<[f64; 2]> {
self.validate()?;
let d = point - v(self.origin);
let u = v(self.u);
let vv = v(self.v);
let uv = [d.dot(u) / u.len2(), d.dot(vv) / vv.len2()];
if uv.iter().any(|c| !c.is_finite() || c.abs() > MAX_PLANAR_REPEAT) {
return Err(invalid("planar UV exceeds the GPU precision bound of 128 repeats"));
}
Ok(uv)
}
pub fn mapped_normal(self, normal: Vec3, geometric: Vec3, rgb: [f64; 3], strength: f64) -> Result<Vec3> {
self.validate()?;
if !strength.is_finite() || !(0.0..=1.0).contains(&strength) || rgb.iter().any(|c| !c.is_finite() || !(0.0..=1.0).contains(c)) {
return Err(invalid("invalid tangent normal input"));
}
let n = unit_direction(normal).map_err(|e| invalid(e.to_string()))?;
let g = unit_direction(geometric).map_err(|e| invalid(e.to_string()))?;
let u = unit_direction(v(self.u)).map_err(|e| invalid(e.to_string()))?;
let tangent = u - n * n.dot(u);
if tangent.len2() < 1e-12 {
return Ok(n);
}
let tangent = unit_direction(tangent).map_err(|e| invalid(e.to_string()))?;
let handedness = if n.dot(v(self.u).cross(v(self.v))) < 0.0 { -1.0 } else { 1.0 };
let local = Vec3::new((2.0 * rgb[0] - 1.0) * strength, (2.0 * rgb[1] - 1.0) * strength, 2.0 * rgb[2] - 1.0);
if local.len2() < 1e-12 || local.z <= 0.0 {
return Ok(n);
}
let mapped =
unit_direction(tangent * local.x + n.cross(tangent) * (handedness * local.y) + n * local.z).map_err(|e| invalid(e.to_string()))?;
Ok(if mapped.dot(g) <= 0.0 { n } else { mapped })
}
}
#[derive(Clone)]
pub struct TextureMap {
source: TextureSource,
retained_bytes: usize,
}
impl std::fmt::Debug for TextureMap {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextureMap").field("info", self.source.info()).finish()
}
}
impl TextureMap {
pub fn new(bytes: Vec<u8>, role: MapRole) -> Result<Self> {
if !matches!(role, MapRole::BaseColor | MapRole::NormalOpenGl) {
return Err(invalid("only sRGB base color and OpenGL tangent normal maps are supported"));
}
let retained_bytes = bytes.capacity();
let source = TextureSource::new(bytes, role).map_err(|e| invalid(e.to_string()))?;
if source.info().width > 1024 || source.info().height > 1024 {
return Err(invalid("runtime texture maps are limited to 1024x1024"));
}
Ok(Self { source, retained_bytes })
}
pub fn retained_bytes(&self) -> usize {
self.retained_bytes
}
pub fn dimensions(&self) -> [u32; 2] {
[self.source.info().width, self.source.info().height]
}
pub fn role(&self) -> MapRole {
self.source.info().role
}
pub fn decode(&self, cancel: &CancelToken) -> Result<Vec<f32>> {
cancel.check()?;
let decoded = self.source.decode(|| cancel.is_cancelled()).map_err(|e| {
if cancel.is_cancelled() { JobError::new(ErrorCode::Cancelled, "texture decoding cancelled") } else { invalid(e.to_string()) }
})?;
let mut rgba = Vec::new();
rgba.try_reserve_exact(decoded.rgba().len()).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "texture pixel allocation failed"))?;
for (index, pixel) in decoded.rgba().as_chunks::<4>().0.iter().enumerate() {
if index % 1024 == 0 {
cancel.check()?;
}
for (channel, value) in pixel.iter().enumerate() {
let linear = if self.role() == MapRole::BaseColor && channel < 3 { srgb8_to_linear(*value) } else { f64::from(*value) / 255.0 };
rgba.push(linear as f32);
}
}
Ok(rgba)
}
}
#[derive(Clone, Debug)]
pub struct TextureBinding {
pub body_index: usize,
pub body_name: String,
pub frame: PlanarFrame,
pub normal_strength: f64,
pub base_color: Option<TextureMap>,
pub normal: Option<TextureMap>,
}
#[derive(Clone, Debug)]
pub struct RuntimeTextures {
bindings: Vec<TextureBinding>,
source_bytes: u64,
pixel_bytes: u64,
}
#[derive(Clone, Debug, Serialize)]
pub struct TextureSummary {
pub bodies: usize,
pub encoded_bytes: u64,
pub linear_pixel_bytes: u64,
}
impl RuntimeTextures {
pub fn new(bindings: Vec<TextureBinding>) -> Result<Self> {
if bindings.is_empty() || bindings.len() > MAX_TEXTURE_BINDINGS {
return Err(invalid("runtime textures require 1..16 distinct body bindings"));
}
let mut seen = std::collections::BTreeSet::new();
let mut source_bytes = 0;
let mut pixel_bytes = 0;
for binding in &bindings {
binding.frame.validate()?;
if binding.body_index >= 16384
|| binding.body_name.is_empty()
|| binding.body_name.len() > 1024
|| binding.body_name.chars().any(char::is_control)
|| !seen.insert(binding.body_index)
|| !binding.normal_strength.is_finite()
|| !(0.0..=1.0).contains(&binding.normal_strength)
|| (binding.base_color.is_none() && binding.normal.is_none())
{
return Err(invalid("invalid or duplicate runtime texture body binding"));
}
if binding.base_color.as_ref().is_some_and(|m| m.role() != MapRole::BaseColor)
|| binding.normal.as_ref().is_some_and(|m| m.role() != MapRole::NormalOpenGl)
{
return Err(invalid("texture role does not match its binding"));
}
for map in [&binding.base_color, &binding.normal].into_iter().flatten() {
source_bytes += map.retained_bytes as u64;
pixel_bytes += map.source.info().rgba_bytes * 4;
}
}
if source_bytes > MAX_TEXTURE_SOURCE_BYTES || pixel_bytes > MAX_TEXTURE_PIXELS_BYTES {
return Err(invalid("runtime textures exceed 16 MiB encoded or 32 MiB linear pixel budget"));
}
Ok(Self { bindings, source_bytes, pixel_bytes })
}
pub fn bindings(&self) -> &[TextureBinding] {
&self.bindings
}
pub fn summary(&self) -> TextureSummary {
TextureSummary { bodies: self.bindings.len(), encoded_bytes: self.source_bytes, linear_pixel_bytes: self.pixel_bytes }
}
/// Source snapshots, codec scratch, decoded bytes, staging and device buffer,
/// plus lookup records. Conservatively includes overlapping preparation.
pub fn working_bytes(&self) -> u64 {
self.source_bytes + self.pixel_bytes * 4 + 64 * 1024 * 1024 + 1024 * 1024
}
pub fn validate_scene(&self, scene: &ProductScene) -> Result<()> {
for binding in &self.bindings {
let body = scene
.meshes
.get(binding.body_index)
.filter(|body| body.name == binding.body_name)
.ok_or_else(|| invalid("texture ordinal and exact body name must match render.scene"))?;
if body.opacity != 1.0 || body.transmission.is_some() || body.faces.iter().any(|f| f.opacity != 1.0 || f.transmission.is_some()) {
return Err(invalid("texture maps currently support opaque bodies only; glass and alpha maps are unsupported"));
}
let bounds = body.mesh.bounds();
for x in [bounds.min.x, bounds.max.x] {
for y in [bounds.min.y, bounds.max.y] {
for z in [bounds.min.z, bounds.max.z] {
binding.frame.uv(Vec3::new(x, y, z))?;
}
}
}
}
Ok(())
}
}
/// Independent f64 bilinear reference over the worker's normalized pixels.
pub fn sample_map(rgba: &[f32], dimensions: [u32; 2], uv: [f64; 2]) -> Result<[f64; 3]> {
TextureView::new(rgba, dimensions).and_then(|v| v.sample_repeat_opaque(uv)).map_err(|e| invalid(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use image::ImageEncoder;
fn png(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
image::codecs::png::PngEncoder::new(&mut bytes).write_image(pixels, width, height, image::ExtendedColorType::Rgba8).unwrap();
bytes
}
fn frame() -> PlanarFrame {
PlanarFrame { origin: [0.; 3], u: [2., 0., 0.], v: [0., 4., 0.] }
}
fn binding() -> TextureBinding {
TextureBinding {
body_index: 0,
body_name: "opaque".into(),
frame: frame(),
normal_strength: 1.,
base_color: Some(TextureMap::new(png(1, 1, &[128, 64, 255, 0]), MapRole::BaseColor).unwrap()),
normal: None,
}
}
#[test]
fn explicit_color_semantics_and_repeat_filter_ignore_alpha() {
let color = TextureMap::new(png(2, 1, &[128, 64, 255, 0, 255, 0, 128, 255]), MapRole::BaseColor).unwrap();
let data = TextureMap::new(png(1, 1, &[128, 64, 255, 0]), MapRole::NormalOpenGl).unwrap();
let c = color.decode(&CancelToken::default()).unwrap();
let n = data.decode(&CancelToken::default()).unwrap();
assert!((f64::from(c[0]) - srgb8_to_linear(128)).abs() < 1e-7);
assert!((n[0] - 128. / 255.).abs() < 1e-7);
let a = sample_map(&c, [2, 1], [0.25, 0.5]).unwrap();
let b = sample_map(&c, [2, 1], [-0.75, 100.5]).unwrap();
assert_eq!(a, b);
assert!((a[0] - srgb8_to_linear(128)).abs() < 1e-7);
let seam = sample_map(&c, [2, 1], [0., 0.]).unwrap();
assert!((seam[0] - (1. + a[0]) / 2.).abs() < 1e-7);
let cancel = CancelToken::default();
cancel.cancel();
assert_eq!(color.decode(&cancel).unwrap_err().code, ErrorCode::Cancelled);
}
#[test]
fn tangent_frame_preserves_handedness_and_falls_back_safely() {
let f = frame();
assert_eq!(f.uv(Vec3::new(-1., 2., 900.)).unwrap(), [-0.5, 0.5]);
let normal = f.mapped_normal(Vec3::Z, Vec3::Z, [0.5, 1., 1.], 1.).unwrap();
assert!((normal - Vec3::new(0., 1., 1.).normalized().unwrap()).len() < 1e-12);
let mirrored = PlanarFrame { v: [0., -4., 0.], ..f }.mapped_normal(Vec3::Z, Vec3::Z, [0.5, 1., 1.], 1.).unwrap();
assert!((mirrored - Vec3::new(0., -1., 1.).normalized().unwrap()).len() < 1e-12);
for rgb in [[0.5; 3], [0., 0., 0.], [1., 1., 0.5]] {
assert_eq!(f.mapped_normal(Vec3::Z, Vec3::Z, rgb, 1.).unwrap(), Vec3::Z);
}
assert_eq!(f.mapped_normal(Vec3::X, Vec3::X, [1.; 3], 1.).unwrap(), Vec3::X);
assert!(PlanarFrame { u: [0.; 3], ..f }.validate().is_err());
assert!(PlanarFrame { v: [1., 4., 0.], ..f }.validate().is_err());
assert!(f.uv(Vec3::new(258., 0., 0.)).is_err());
}
#[test]
fn bounded_resources_reject_duplicate_roles_and_encoded_capacity() {
let b = binding();
let t = RuntimeTextures::new(vec![b.clone()]).unwrap();
assert_eq!(t.summary().linear_pixel_bytes, 16);
assert!(RuntimeTextures::new(vec![b.clone(), b]).is_err());
let mut wrong = binding();
wrong.normal = wrong.base_color.take();
assert!(RuntimeTextures::new(vec![wrong]).is_err());
let mut bytes = png(1, 1, &[0; 4]);
bytes.reserve_exact(16 * 1024 * 1024);
assert!(TextureMap::new(bytes, MapRole::BaseColor).is_err());
assert!(TextureMap::new(png(1, 1, &[0; 4]), MapRole::Roughness).is_err());
}
}
+5
View File
@@ -5,12 +5,16 @@
#![forbid(unsafe_code)]
mod analysis;
pub mod anisotropic_surface;
mod backend;
mod camera;
pub mod clear_coat;
mod curve_display;
pub mod film;
mod gif;
pub mod job;
pub mod lighting;
pub mod mapping;
mod product;
mod raster;
mod scene;
@@ -20,6 +24,7 @@ pub mod texture_input;
pub mod texture_interpolation;
pub mod texture_lod;
pub mod texture_sampling;
pub mod transmission;
pub use analysis::{AnalysisColors, AnalysisMode, SceneAnalysis};
pub use backend::{
+304
View File
@@ -0,0 +1,304 @@
//! Captured runtime environment input and original exact-solid-angle importance tables.
//! The HDR decoder retains source primaries: admission below makes interpretation explicit.
use super::{HdrDecodeTask, HdrSource, HdrTaskPoll};
use crate::job::{CancelToken, ErrorCode, JobError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use solidcraft_geom::Vec3;
use std::{
f64::consts::{PI, TAU},
sync::Arc,
};
fn invalid(message: impl Into<String>) -> JobError {
JobError::new(ErrorCode::InvalidRequest, message)
}
fn reserve<T>(count: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "environment allocation failed"))?;
Ok(v)
}
/// Original artistic gradient, not a calibrated atmosphere. No imported image assets.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SkyGradient {
pub zenith: [f32; 3],
pub horizon: [f32; 3],
pub ground: [f32; 3],
}
impl Default for SkyGradient {
fn default() -> Self {
Self { zenith: [0.16, 0.32, 0.7], horizon: [0.8, 0.9, 1.0], ground: [0.12, 0.1, 0.08] }
}
}
impl SkyGradient {
pub fn validate(self) -> Result<()> {
if self.zenith.into_iter().chain(self.horizon).chain(self.ground).any(|v| !v.is_finite() || !(0.0..=1e6).contains(&v)) {
return Err(invalid("sky radiance must be finite and within 0..1000000"));
}
Ok(())
}
fn evaluate(self, z: f64) -> [f32; 3] {
let endpoint = if z >= 0.0 { self.zenith } else { self.ground };
let blend = z.abs().sqrt() as f32;
std::array::from_fn(|i| self.horizon[i] * (1.0 - blend) + endpoint[i] * blend)
}
}
#[derive(Clone, Debug, Serialize)]
pub struct EnvironmentInfo {
pub kind: &'static str,
pub width: u32,
pub height: u32,
pub encoded_bytes: u64,
pub source_sha256: String,
pub color_policy: &'static str,
pub rotation_rad: f64,
pub intensity: f64,
}
#[derive(Clone)]
enum Source {
Hdr(HdrSource),
Sky(SkyGradient),
}
/// Immutable captured input. Preparation happens in the image worker, with cancellation.
#[derive(Clone)]
pub struct EnvironmentSource {
source: Source,
info: EnvironmentInfo,
retained_bytes: u64,
}
impl std::fmt::Debug for EnvironmentSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnvironmentSource").field("info", &self.info).finish()
}
}
impl EnvironmentSource {
pub fn hdr(bytes: Vec<u8>, rotation_rad: f64, intensity: f64, assume_linear_srgb: bool) -> Result<Self> {
let retained_bytes = bytes.capacity() as u64;
let source = HdrSource::new(bytes).map_err(|e| invalid(e.to_string()))?;
let primaries = [0.64, 0.33, 0.30, 0.60, 0.15, 0.06, 0.3127, 0.3290];
let color_policy = match source.info().primaries {
Some(actual) if actual.iter().zip(primaries).all(|(a, b)| (a - b).abs() < 1e-5) => "declared_linear_srgb_d65",
Some(_) => return Err(invalid("HDR primaries must be sRGB/D65; convert the source before rendering")),
None if assume_linear_srgb => "explicit_assumed_linear_srgb_d65",
None => {
return Err(invalid("HDR primaries are unspecified: explicitly set assume_linear_srgb or supply a converted source with PRIMARIES"));
}
};
let info = EnvironmentInfo {
kind: "radiance_hdr",
width: source.info().width,
height: source.info().height,
encoded_bytes: source.info().encoded_bytes,
source_sha256: format!("{:x}", Sha256::digest(source.encoded_bytes())),
color_policy,
rotation_rad,
intensity,
};
let value = Self { source: Source::Hdr(source), info, retained_bytes };
value.validate()?;
Ok(value)
}
pub fn sky(sky: SkyGradient, rotation_rad: f64, intensity: f64) -> Result<Self> {
sky.validate()?;
let mut hash = Sha256::new();
hash.update(b"solidcraft.original-gradient-sky/1");
for v in sky.zenith.into_iter().chain(sky.horizon).chain(sky.ground) {
hash.update(v.to_le_bytes());
}
let value = Self {
source: Source::Sky(sky),
retained_bytes: 0,
info: EnvironmentInfo {
kind: "original_gradient_sky",
width: 512,
height: 256,
encoded_bytes: 0,
source_sha256: format!("{:x}", hash.finalize()),
color_policy: "authored_linear_srgb_d65",
rotation_rad,
intensity,
},
};
value.validate()?;
Ok(value)
}
pub fn info(&self) -> &EnvironmentInfo {
&self.info
}
pub fn validate(&self) -> Result<()> {
if !self.info.rotation_rad.is_finite()
|| self.info.rotation_rad.abs() > 1e6
|| !self.info.intensity.is_finite()
|| !(0.0..=100.0).contains(&self.info.intensity)
{
return Err(invalid("environment rotation must be finite within +/-1000000 radians and intensity 0..100"));
}
Ok(())
}
/// Conservative additional peak: encoded bytes, decode/table/alias work, staging and GPU storage.
pub fn admitted_bytes(&self) -> u64 {
self.retained_bytes + u64::from(self.info.width) * u64::from(self.info.height) * 160 + 65536
}
pub fn prepare(&self, cancel: &CancelToken) -> Result<PreparedEnvironment> {
self.validate()?;
cancel.check()?;
let pixels = match &self.source {
Source::Hdr(source) => {
let mut task = HdrDecodeTask::new(source.clone()).map_err(|e| invalid(e.to_string()))?;
let decoded = loop {
cancel.check()?;
match task.step(16384, || cancel.is_cancelled()).map_err(|e| {
if cancel.is_cancelled() {
JobError::new(ErrorCode::Cancelled, "environment preparation cancelled")
} else {
invalid(e.to_string())
}
})? {
HdrTaskPoll::Pending(_) => {}
HdrTaskPoll::Complete(decoded) => break decoded,
}
};
let mut out = reserve(decoded.pixels().len())?;
out.extend_from_slice(decoded.pixels());
out
}
Source::Sky(sky) => {
let count = (self.info.width * self.info.height) as usize;
let mut out = reserve(count)?;
for y in 0..self.info.height {
cancel.check()?;
let z = (PI * (f64::from(y) + 0.5) / f64::from(self.info.height)).cos();
for _ in 0..self.info.width {
out.push(sky.evaluate(z));
}
}
out
}
};
PreparedEnvironment::new(self.info.width, self.info.height, pixels, cancel)
}
}
#[derive(Clone, Copy, Debug)]
pub struct EnvironmentTexel {
pub rgb: [f32; 3],
/// Density per steradian, not probability per texel.
pub pdf: f32,
pub threshold: f32,
pub alias: u32,
}
pub struct PreparedEnvironment {
width: u32,
height: u32,
texels: Vec<EnvironmentTexel>,
}
impl PreparedEnvironment {
fn new(width: u32, height: u32, pixels: Vec<[f32; 3]>, cancel: &CancelToken) -> Result<Self> {
let count = (width * height) as usize;
if count != pixels.len() || count == 0 {
return Err(invalid("invalid environment storage"));
}
let mut masses = reserve(count)?;
let mut total = 0.0;
for (i, rgb) in pixels.iter().enumerate() {
if i % 1024 == 0 {
cancel.check()?;
}
let mass = (0.2126 * f64::from(rgb[0]) + 0.7152 * f64::from(rgb[1]) + 0.0722 * f64::from(rgb[2]))
* row_solid_angle(width, height, i / width as usize);
total += mass;
masses.push(mass);
}
if total == 0.0 {
for (i, mass) in masses.iter_mut().enumerate() {
*mass = row_solid_angle(width, height, i / width as usize);
}
total = 4.0 * PI;
}
let mut small = reserve(count)?;
let mut large = reserve(count)?;
let mut texels = reserve(count)?;
for (i, (rgb, mass)) in pixels.into_iter().zip(masses.iter_mut()).enumerate() {
if i % 1024 == 0 {
cancel.check()?;
}
let probability = *mass / total;
texels.push(EnvironmentTexel {
rgb,
pdf: (probability / row_solid_angle(width, height, i / width as usize)) as f32,
threshold: 1.0,
alias: i as u32,
});
*mass = probability * count as f64;
if *mass < 1.0 {
small.push(i);
} else {
large.push(i);
}
}
while !small.is_empty() && !large.is_empty() {
cancel.check()?;
let low = small.pop().ok_or_else(|| invalid("invalid alias table"))?;
let high = large.pop().ok_or_else(|| invalid("invalid alias table"))?;
let value = *masses.get(low).ok_or_else(|| invalid("invalid alias mass"))?;
let entry = texels.get_mut(low).ok_or_else(|| invalid("invalid alias index"))?;
entry.threshold = value as f32;
entry.alias = high as u32;
let remaining = masses.get_mut(high).ok_or_else(|| invalid("invalid alias mass"))?;
*remaining = (*remaining + value - 1.0).max(0.0);
if *remaining < 1.0 {
small.push(high);
} else {
large.push(high);
}
}
cancel.check()?;
Ok(Self { width, height, texels })
}
pub fn dimensions(&self) -> [u32; 2] {
[self.width, self.height]
}
pub fn texels(&self) -> &[EnvironmentTexel] {
&self.texels
}
/// CPU oracle of the same original piecewise-constant sphere mapping as WGSL.
pub fn sample(&self, choice: f64, coin: f64, azimuth: f64, vertical: f64) -> Result<(Vec3, [f32; 3], f32)> {
if [choice, coin, azimuth, vertical].into_iter().any(|v| !v.is_finite() || !(0.0..1.0).contains(&v)) {
return Err(invalid("environment samples must be in [0,1)"));
}
let scaled = choice * self.texels.len() as f64;
let index = scaled.floor() as usize;
let entry = self.texels.get(index).ok_or_else(|| invalid("environment sample index"))?;
let index = if coin < f64::from(entry.threshold) { index } else { entry.alias as usize };
let selected = self.texels.get(index).ok_or_else(|| invalid("environment alias index"))?;
let row = index / self.width as usize;
let column = index % self.width as usize;
let top = (PI * row as f64 / f64::from(self.height)).cos();
let bottom = (PI * (row + 1) as f64 / f64::from(self.height)).cos();
let z = top + (bottom - top) * vertical;
let phi = TAU * (column as f64 + azimuth) / f64::from(self.width);
let radius = (1.0 - z * z).max(0.0).sqrt();
Ok((Vec3::new(radius * phi.cos(), radius * phi.sin(), z), selected.rgb, selected.pdf))
}
pub fn evaluate(&self, direction: Vec3) -> Result<([f32; 3], f32)> {
let d = crate::surface::unit_direction(direction).map_err(|_| invalid("invalid environment direction"))?;
let column = ((d.y.atan2(d.x).rem_euclid(TAU) / TAU * f64::from(self.width)) as usize).min(self.width as usize - 1);
let row = ((d.z.clamp(-1.0, 1.0).acos() / PI * f64::from(self.height)) as usize).min(self.height as usize - 1);
let texel = self.texels.get(row * self.width as usize + column).ok_or_else(|| invalid("environment lookup index"))?;
Ok((texel.rgb, texel.pdf))
}
}
fn row_solid_angle(width: u32, height: u32, row: usize) -> f64 {
// Stable difference of cosines at polar rows.
let half = PI / (2.0 * f64::from(height));
(TAU / f64::from(width)) * 2.0 * (PI * (row as f64 + 0.5) / f64::from(height)).sin() * half.sin()
}
/// Shared captured source, cheap to clone into queued jobs.
pub type EnvironmentResource = Arc<EnvironmentSource>;
#[cfg(test)]
mod tests;
@@ -0,0 +1,104 @@
use super::*;
fn hdr(extra: &str, pixels: &[[u8; 4]]) -> Vec<u8> {
let mut bytes = format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n{extra}\n-Y 2 +X 4\n").into_bytes();
for pixel in pixels {
bytes.extend_from_slice(pixel);
}
bytes
}
#[test]
fn captured_hdr_requires_explicit_color_policy_and_prepares_without_clipping() {
let bytes = hdr("", &[[128, 64, 32, 130]; 8]);
assert!(EnvironmentSource::hdr(bytes.clone(), 0.0, 1.0, false).is_err());
let source = EnvironmentSource::hdr(bytes, 0.3, 2.0, true).unwrap();
assert_eq!(source.info().source_sha256.len(), 64);
assert_eq!(source.info().color_policy, "explicit_assumed_linear_srgb_d65");
let prepared = source.prepare(&CancelToken::default()).unwrap();
assert_eq!(prepared.dimensions(), [4, 2]);
assert!(prepared.texels().iter().all(|t| t.rgb == [2.0, 1.0, 0.5]));
let wrong = hdr("PRIMARIES=0.67 0.33 0.21 0.71 0.14 0.08 0.31 0.33\n", &[[128, 128, 128, 129]; 8]);
assert!(EnvironmentSource::hdr(wrong, 0.0, 1.0, true).is_err());
let declared = hdr("PRIMARIES=0.64 0.33 0.30 0.60 0.15 0.06 0.3127 0.3290\n", &[[128, 128, 128, 129]; 8]);
assert!(EnvironmentSource::hdr(declared, 0.0, 1.0, false).is_ok());
assert!(EnvironmentSource::sky(SkyGradient::default(), f64::NAN, 1.0).is_err());
assert!(EnvironmentSource::sky(SkyGradient::default(), 0.0, 101.0).is_err());
let token = CancelToken::default();
token.cancel();
assert_eq!(source.prepare(&token).err().unwrap().code, ErrorCode::Cancelled);
}
#[test]
fn exact_solid_angle_distribution_handles_poles_black_and_white_maps() {
for color in [[0.0; 3], [1.0; 3]] {
let map = PreparedEnvironment::new(64, 32, vec![color; 2048], &CancelToken::default()).unwrap();
let total: f64 = map.texels().iter().enumerate().map(|(i, t)| f64::from(t.pdf) * row_solid_angle(64, 32, i / 64)).sum();
assert!((total - 1.0).abs() < 1e-7);
for t in map.texels() {
assert!((f64::from(t.pdf) - 1.0 / (4.0 * PI)).abs() < 1e-8);
}
for d in [Vec3::Z, -Vec3::Z, Vec3::X, Vec3::new(1.0, -1e-12, 0.0)] {
let (value, pdf) = map.evaluate(d).unwrap();
assert_eq!(value, color);
assert!((f64::from(pdf) - 1.0 / (4.0 * PI)).abs() < 1e-8);
}
}
}
#[test]
fn alias_samples_match_unequal_texel_probabilities_and_lookup_pdf() {
let mut pixels = vec![[0.0; 3]; 32];
pixels[2] = [1.0; 3];
pixels[11] = [4.0; 3];
pixels[29] = [2.0; 3];
let map = PreparedEnvironment::new(8, 4, pixels, &CancelToken::default()).unwrap();
let mut counts = [0usize; 32];
let mut rng = 17u64;
let mut random = || {
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
(rng >> 11) as f64 / (1u64 << 53) as f64
};
let count = 200_000;
for _ in 0..count {
let (direction, rgb, pdf) = map.sample(random(), random(), random(), random()).unwrap();
let (actual, actual_pdf) = map.evaluate(direction).unwrap();
assert_eq!(rgb, actual);
assert_eq!(pdf, actual_pdf);
let index = if rgb == [1.0; 3] {
2
} else if rgb == [4.0; 3] {
11
} else {
assert_eq!(rgb, [2.0; 3]);
29
};
counts[index] += 1;
}
for i in [2, 11, 29] {
let expected = f64::from(map.texels()[i].pdf) * row_solid_angle(8, 4, i / 8);
assert!((counts[i] as f64 / f64::from(count) - expected).abs() < 0.004);
}
}
#[test]
fn original_sky_is_bounded_cancellable_and_reproducible() {
let source = EnvironmentSource::sky(SkyGradient::default(), 0.0, 1.0).unwrap();
let a = source.prepare(&CancelToken::default()).unwrap();
assert_eq!(a.dimensions(), [512, 256]);
let (zenith, _) = a.evaluate(Vec3::Z).unwrap();
let (horizon, _) = a.evaluate(Vec3::X).unwrap();
let (ground, _) = a.evaluate(-Vec3::Z).unwrap();
assert!(zenith[2] > zenith[0]);
assert!(horizon[0] > zenith[0]);
assert!(ground[0] > ground[2]);
assert!(source.admitted_bytes() > a.texels().len() as u64 * 64);
assert!(EnvironmentSource::sky(SkyGradient { zenith: [f32::INFINITY; 3], ..Default::default() }, 0.0, 1.0).is_err());
}
#[test]
fn hdr_admission_counts_retained_capacity_not_just_file_length() {
let bytes = hdr("", &[[128, 128, 128, 129]; 8]);
let mut overallocated = Vec::with_capacity(super::super::MAX_HDR_BYTES);
overallocated.extend_from_slice(&bytes);
let retained = overallocated.capacity() as u64;
let source = EnvironmentSource::hdr(overallocated, 0.0, 1.0, true).unwrap();
assert_eq!(source.info().encoded_bytes, bytes.len() as u64);
assert_eq!(source.admitted_bytes(), retained + 8 * 160 + 65536);
}
+1
View File
@@ -1,5 +1,6 @@
//! Device-independent, bounded linear HDR input preparation.
pub mod environment;
mod hdr;
mod task;
pub use hdr::{HdrInfo, HdrSource, MAX_HDR_BYTES, MAX_HDR_HEIGHT, MAX_HDR_PIXELS, MAX_HDR_WIDTH};
+141
View File
@@ -0,0 +1,141 @@
//! Definition-local visual coordinates, independent of geometry and engineering axes.
//! This numerical foundation installs no texture sampler, shader or persistent record.
use solidcraft_geom::{Aabb3, Vec3};
/// Admission bounds, not a GPU precision or engineering applicability guarantee.
pub const MAX_LOCAL_MM: f64 = 1e9;
pub const MIN_REPEAT_MM: f64 = 1e-3;
pub const MAX_REPEAT_MM: f64 = 1e6;
pub const MAX_REPEAT_COORDINATE: f64 = 1e6;
/// Absolute tolerance for unit-length squared, orthogonality and handedness checks.
pub const FRAME_TOLERANCE: f64 = 1e-10;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MappingError {
Position,
Frame,
RepeatSize,
Coordinates,
Bounds,
}
impl std::fmt::Display for MappingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Position => "visual mapping positions must be finite and within +/-1e9 mm",
Self::Frame => "visual mapping axes must form a right-handed orthonormal frame",
Self::RepeatSize => "visual repeat sizes must be finite and within [0.001, 1e6] mm",
Self::Coordinates => "visual repeat coordinates exceed the +/-1e6 admission limit",
Self::Bounds => "grain suggestion needs finite ordered local bounds with a positive extent",
})
}
}
impl std::error::Error for MappingError {}
/// A one-time visual suggestion; not a material-local mechanical L/R/T assignment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrainAxis {
X,
Y,
Z,
}
impl GrainAxis {
/// First axis is the visual grain direction. The other two complete a right-handed frame.
pub fn axes(self) -> [Vec3; 3] {
match self {
Self::X => [Vec3::X, Vec3::Y, Vec3::Z],
Self::Y => [Vec3::Y, Vec3::Z, Vec3::X],
Self::Z => [Vec3::Z, Vec3::X, Vec3::Y],
}
}
}
/// Suggest from definition-local bounds only, with exact ties resolved X, then Y, then Z.
/// The caller stores an accepted frame once; this function never observes a later rebuild.
pub fn suggest_grain_axis(local_bounds: Aabb3) -> Result<GrainAxis, MappingError> {
if !bounded_position(local_bounds.min) || !bounded_position(local_bounds.max) || local_bounds.is_empty() {
return Err(MappingError::Bounds);
}
let extent = local_bounds.max - local_bounds.min;
if extent.x.max(extent.y).max(extent.z) <= 0.0 {
return Err(MappingError::Bounds);
}
if extent.x >= extent.y && extent.x >= extent.z {
Ok(GrainAxis::X)
} else if extent.y >= extent.z {
Ok(GrainAxis::Y)
} else {
Ok(GrainAxis::Z)
}
}
/// Immutable admitted visual frame. Fields cannot be changed without readmission.
/// No serde implementation: eventual document/catalog readers must use `new`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VisualMapping {
origin_mm: Vec3,
axes: [Vec3; 3],
repeat_mm: Vec3,
}
impl VisualMapping {
/// Axes are unit directions in definition-local space, in grain/across/depth order.
/// Repeat sizes are distances along those directions, not body-bound fractions.
/// Small floating-point frame error is admitted within `FRAME_TOLERANCE`; no skew,
/// reflection, rescaling, orthogonalization or replacement frame is manufactured.
pub fn new(origin_mm: Vec3, axes: [Vec3; 3], repeat_mm: Vec3) -> Result<Self, MappingError> {
if !bounded_position(origin_mm) {
return Err(MappingError::Position);
}
let [x, y, z] = axes;
if !axes.into_iter().all(|v| v.is_finite() && (v.len2() - 1.0).abs() <= FRAME_TOLERANCE)
|| x.dot(y).abs() > FRAME_TOLERANCE
|| x.dot(z).abs() > FRAME_TOLERANCE
|| y.dot(z).abs() > FRAME_TOLERANCE
|| (x.cross(y).dot(z) - 1.0).abs() > FRAME_TOLERANCE
{
return Err(MappingError::Frame);
}
if ![repeat_mm.x, repeat_mm.y, repeat_mm.z].into_iter().all(|v| v.is_finite() && (MIN_REPEAT_MM..=MAX_REPEAT_MM).contains(&v)) {
return Err(MappingError::RepeatSize);
}
Ok(Self { origin_mm, axes, repeat_mm })
}
pub fn origin_mm(self) -> Vec3 {
self.origin_mm
}
pub fn axes(self) -> [Vec3; 3] {
self.axes
}
pub fn repeat_mm(self) -> Vec3 {
self.repeat_mm
}
/// Unwrapped signed repeat coordinates, calculated in f64. No clamping or UV seam.
/// The host must first invert the selected instance placement into definition-local
/// space. World-space bounding boxes and camera/mesh positions are not inputs here.
/// Samplers choose wrapping/filtering; GPU consumers need their own precision budget.
pub fn coordinates(self, local_point_mm: Vec3) -> Result<Vec3, MappingError> {
if !bounded_position(local_point_mm) {
return Err(MappingError::Position);
}
let delta = local_point_mm - self.origin_mm;
let [x, y, z] = self.axes;
let result = Vec3::new(delta.dot(x) / self.repeat_mm.x, delta.dot(y) / self.repeat_mm.y, delta.dot(z) / self.repeat_mm.z);
if !result.is_finite() || [result.x, result.y, result.z].into_iter().any(|v| v.abs() > MAX_REPEAT_COORDINATE) {
return Err(MappingError::Coordinates);
}
Ok(result)
}
}
fn bounded_position(value: Vec3) -> bool {
value.is_finite() && [value.x, value.y, value.z].into_iter().all(|v| v.abs() <= MAX_LOCAL_MM)
}
#[cfg(test)]
#[path = "mapping_tests.rs"]
mod tests;
+112
View File
@@ -0,0 +1,112 @@
use super::*;
fn close(actual: Vec3, expected: Vec3) {
assert!((actual - expected).len() < 1e-10, "{actual:?} != {expected:?}");
}
#[test]
fn physical_repeats_translation_negative_coordinates_and_manual_rotation() {
// +90 degrees around local Z: grain +Y, across -X, depth +Z.
let origin = Vec3::new(10.0, -20.0, 30.0);
let mapping = VisualMapping::new(origin, [Vec3::Y, -Vec3::X, Vec3::Z], Vec3::new(100.0, 20.0, 5.0)).unwrap();
close(mapping.coordinates(origin).unwrap(), Vec3::ZERO);
close(mapping.coordinates(origin + Vec3::new(-10.0, 200.0, -15.0)).unwrap(), Vec3::new(2.0, 0.5, -3.0));
// One repeat is a physical distance, independent of a body's dimensions.
close(mapping.coordinates(origin + Vec3::Y * 100.0).unwrap(), Vec3::X);
assert_eq!(mapping.origin_mm(), origin);
assert_eq!(mapping.axes(), [Vec3::Y, -Vec3::X, Vec3::Z]);
assert_eq!(mapping.repeat_mm(), Vec3::new(100.0, 20.0, 5.0));
}
#[test]
fn oblique_frame_has_analytic_coordinates_without_automatic_orthogonalization() {
let a = std::f64::consts::FRAC_1_SQRT_2;
let axes = [Vec3::new(a, a, 0.0), Vec3::new(-a, a, 0.0), Vec3::Z];
let mapping = VisualMapping::new(Vec3::ZERO, axes, Vec3::new(2.0, 3.0, 4.0)).unwrap();
close(mapping.coordinates(Vec3::new(2.0 * a, 2.0 * a, 0.0)).unwrap(), Vec3::X);
close(mapping.coordinates(Vec3::new(-3.0 * a, 3.0 * a, 4.0)).unwrap(), Vec3::new(0.0, 1.0, 1.0));
assert_eq!(mapping.axes(), axes);
}
#[test]
fn longest_local_axis_uses_xyz_ties_and_accepts_planar_bounds() {
for (extent, expected) in [
(Vec3::new(3.0, 3.0, 3.0), GrainAxis::X),
(Vec3::new(2.0, 3.0, 3.0), GrainAxis::Y),
(Vec3::new(1.0, 2.0, 3.0), GrainAxis::Z),
(Vec3::new(0.0, 3.0, 0.0), GrainAxis::Y),
] {
let min = Vec3::new(10.0, -4.0, 2.0);
let axis = suggest_grain_axis(Aabb3 { min, max: min + extent }).unwrap();
assert_eq!(axis, expected);
assert!(VisualMapping::new(Vec3::ZERO, axis.axes(), Vec3::new(1.0, 1.0, 1.0)).is_ok());
}
}
#[test]
fn accepted_frame_does_not_flip_after_a_different_resize_suggestion() {
let first = suggest_grain_axis(Aabb3 { min: Vec3::ZERO, max: Vec3::new(100.0, 20.0, 10.0) }).unwrap();
let mapping = VisualMapping::new(Vec3::ZERO, first.axes(), Vec3::new(10.0, 10.0, 10.0)).unwrap();
let point = Vec3::new(25.0, 15.0, 5.0);
let before = mapping.coordinates(point).unwrap();
assert_eq!(suggest_grain_axis(Aabb3 { min: Vec3::ZERO, max: Vec3::new(100.0, 200.0, 10.0) }).unwrap(), GrainAxis::Y);
assert_eq!(mapping.coordinates(point).unwrap(), before);
close(before, Vec3::new(2.5, 1.5, 0.5));
}
#[test]
fn rigid_instances_share_definition_coordinates_after_explicit_inverse_placement() {
let mapping = VisualMapping::new(Vec3::new(1.0, 2.0, 3.0), GrainAxis::Z.axes(), Vec3::new(5.0, 10.0, 20.0)).unwrap();
let definition_point = Vec3::new(21.0, -8.0, 13.0);
let expected = Vec3::new(2.0, 2.0, -0.5);
close(mapping.coordinates(definition_point).unwrap(), expected);
// Independent analytic instance transform: (x,y,z) -> (-y,x,z) + translation.
let translation = Vec3::new(-300.0, 70.0, 12.0);
let world = Vec3::new(-definition_point.y, definition_point.x, definition_point.z) + translation;
let shifted = world - translation;
let inverted = Vec3::new(shifted.y, -shifted.x, shifted.z);
close(mapping.coordinates(inverted).unwrap(), expected);
// Passing world points directly is deliberately not corrected by the utility.
assert_ne!(mapping.coordinates(world).unwrap(), expected);
}
#[test]
fn malformed_frames_refuse_instead_of_repairing_or_reflecting() {
for axes in [
[Vec3::ZERO, Vec3::Y, Vec3::Z],
[Vec3::X * 2.0, Vec3::Y, Vec3::Z],
[Vec3::X, Vec3::X, Vec3::Z],
[Vec3::X, Vec3::Y, -Vec3::Z],
[Vec3::X, Vec3::new(0.01, 1.0, 0.0), Vec3::Z],
[Vec3::new(f64::NAN, 0.0, 0.0), Vec3::Y, Vec3::Z],
[Vec3::new(f64::MAX, 0.0, 0.0), Vec3::Y, Vec3::Z],
] {
assert_eq!(VisualMapping::new(Vec3::ZERO, axes, Vec3::new(1.0, 1.0, 1.0)), Err(MappingError::Frame));
}
}
#[test]
fn hostile_positions_sizes_bounds_and_excessive_coordinates_refuse() {
let mapping = VisualMapping::new(Vec3::ZERO, GrainAxis::X.axes(), Vec3::new(1.0, 1.0, 1.0)).unwrap();
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, MAX_LOCAL_MM * 2.0] {
let point = Vec3::new(value, 0.0, 0.0);
assert_eq!(VisualMapping::new(point, GrainAxis::X.axes(), Vec3::new(1.0, 1.0, 1.0)), Err(MappingError::Position));
assert_eq!(mapping.coordinates(point), Err(MappingError::Position));
assert_eq!(suggest_grain_axis(Aabb3 { min: Vec3::ZERO, max: point }), Err(MappingError::Bounds));
}
for value in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MIN_POSITIVE, MIN_REPEAT_MM * 0.5, MAX_REPEAT_MM * 2.0] {
assert_eq!(VisualMapping::new(Vec3::ZERO, GrainAxis::X.axes(), Vec3::new(1.0, value, 1.0)), Err(MappingError::RepeatSize));
}
for bounds in [Aabb3::EMPTY, Aabb3 { min: Vec3::X, max: Vec3::ZERO }, Aabb3 { min: Vec3::ZERO, max: Vec3::ZERO }] {
assert_eq!(suggest_grain_axis(bounds), Err(MappingError::Bounds));
}
assert_eq!(mapping.coordinates(Vec3::new(MAX_REPEAT_COORDINATE + 1.0, 0.0, 0.0)), Err(MappingError::Coordinates));
}
#[test]
fn admitted_boundaries_remain_finite() {
let mapping = VisualMapping::new(Vec3::new(MAX_LOCAL_MM, 0.0, 0.0), GrainAxis::X.axes(), Vec3::new(MAX_REPEAT_MM, MIN_REPEAT_MM, 1.0)).unwrap();
close(mapping.coordinates(Vec3::new(-MAX_LOCAL_MM, 0.0, 0.0)).unwrap(), Vec3::new(-2000.0, 0.0, 0.0));
let tiny = VisualMapping::new(Vec3::ZERO, GrainAxis::X.axes(), Vec3::new(MIN_REPEAT_MM, 1.0, 1.0)).unwrap();
close(tiny.coordinates(Vec3::new(1000.0, 0.0, 0.0)).unwrap(), Vec3::new(MAX_REPEAT_COORDINATE, 0.0, 0.0));
}
+5
View File
@@ -11,6 +11,7 @@ pub struct FaceAppearance {
pub color: [u8; 3],
pub opacity: f64,
pub surface: Option<crate::surface::OpaqueSurface>,
pub transmission: Option<crate::transmission::Transmission>,
}
#[derive(Clone, Debug)]
@@ -22,6 +23,7 @@ pub struct ProductMesh {
pub color: [u8; 3],
pub opacity: f64,
pub surface: Option<crate::surface::OpaqueSurface>,
pub transmission: Option<crate::transmission::Transmission>,
pub faces: Vec<FaceAppearance>,
}
@@ -32,6 +34,9 @@ pub struct ProductScene {
}
impl ProductScene {
pub fn has_transmission(&self) -> bool {
self.meshes.iter().any(|m| m.transmission.is_some() || m.faces.iter().any(|f| f.transmission.is_some()))
}
pub fn required_features(&self) -> Vec<crate::RenderFeature> {
if self.meshes.iter().any(|m| m.surface.is_some() || m.faces.iter().any(|f| f.surface.is_some())) {
vec![crate::RenderFeature::Pbr]
+50 -5
View File
@@ -16,12 +16,14 @@ pub struct OpaqueSurface {
metallic: f64,
roughness: f64,
dielectric_ior: f64,
clear_coat: Option<crate::clear_coat::ClearCoat>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SurfaceError {
BaseColor,
Metallic,
CoatWeight,
Roughness,
DielectricIor,
Direction,
@@ -32,6 +34,7 @@ impl std::fmt::Display for SurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::BaseColor => "linear base color channels must be finite and in [0, 1]",
Self::CoatWeight => "clear-coat weight must be finite and in [0, 1]",
Self::Metallic => "metallic fraction must be finite and in [0, 1]",
Self::Roughness => "perceptual roughness must be finite and in [0.045, 1]",
Self::DielectricIor => "dielectric IOR must be finite and in [1, 3]",
@@ -58,7 +61,16 @@ impl OpaqueSurface {
if !dielectric_ior.is_finite() || !(1.0..=3.0).contains(&dielectric_ior) {
return Err(SurfaceError::DielectricIor);
}
Ok(Self { base_color, metallic, roughness, dielectric_ior })
Ok(Self { base_color, metallic, roughness, dielectric_ior, clear_coat: None })
}
/// Runtime optical layer; never serialized into a document appearance.
pub fn with_clear_coat(mut self, coat: crate::clear_coat::ClearCoat) -> Self {
self.clear_coat = if coat.weight() == 0.0 { None } else { Some(coat) };
self
}
pub fn clear_coat(self) -> Option<crate::clear_coat::ClearCoat> {
self.clear_coat
}
pub fn base_color(self) -> Vec3 {
@@ -79,6 +91,14 @@ impl OpaqueSurface {
Vec3::new(ratio * ratio, ratio * ratio, ratio * ratio).lerp(self.base_color, self.metallic)
}
/// Schlick approximation with the exact index-matched dielectric limit.
/// A conductor retains its own grazing response, including in a metal mix.
pub(crate) fn specular_fresnel(self, cosine: f64) -> Vec3 {
let f0 = self.normal_reflectance();
let grazing = if self.dielectric_ior == 1.0 { self.metallic } else { 1.0 };
f0 + (Vec3::new(grazing, grazing, grazing) - f0) * (1.0 - cosine.clamp(0.0, 1.0)).powi(5)
}
/// BRDF in inverse steradians. Directions point away from the surface.
/// No incident radiance, cosine weight, exposure or display transfer is included.
/// Back-facing/tangent directions return zero; malformed directions are errors.
@@ -102,19 +122,19 @@ impl OpaqueSurface {
// G1 / cos(theta) has a finite grazing limit: no epsilon cosine clamp.
let visibility = |cos: f64| 2.0 / (cos + (a2 + (1.0 - a2) * cos * cos).sqrt());
let specular = distribution * 0.25 * visibility(nv) * visibility(nl);
let f0 = self.normal_reflectance();
let fresnel = |cos: f64| f0 + (Vec3::new(1.0, 1.0, 1.0) - f0) * (1.0 - cos).powi(5);
let fresnel = |cos: f64| self.specular_fresnel(cos);
let f = fresnel(vh);
// Symmetric entry/exit attenuation avoids adding a full Lambert substrate
// to grazing reflection. Internal reflection / multi-scattering is omitted.
let fv = fresnel(nv);
let fl = fresnel(nl);
let diffuse = self.base_color * ((1.0 - self.metallic) / PI);
Ok(Vec3::new(
let base = Vec3::new(
f.x * specular + (1.0 - fv.x) * (1.0 - fl.x) * diffuse.x,
f.y * specular + (1.0 - fv.y) * (1.0 - fl.y) * diffuse.y,
f.z * specular + (1.0 - fv.z) * (1.0 - fl.z) * diffuse.z,
))
);
Ok(self.clear_coat.map_or(base, |coat| coat.layer(base, nv, nl, nh, vh)))
}
}
@@ -228,6 +248,31 @@ mod tests {
}
}
#[test]
fn index_matched_dielectric_is_exact_lambert_at_oblique_and_grazing_angles() {
let color = Vec3::new(0.75, 0.35, 0.02);
for roughness in [MIN_ROUGHNESS, 0.25, 1.0] {
let s = OpaqueSurface::new(color, 0.0, roughness, 1.0).unwrap();
for cosine in [0.0, 1e-12, 0.02, 0.5, 1.0] {
assert_eq!(s.specular_fresnel(cosine), Vec3::ZERO);
}
for nv in [1e-8_f64, 0.02, 0.5, 1.0] {
for nl in [1e-8_f64, 0.2, 0.7, 1.0] {
let v = Vec3::new((1.0 - nv * nv).sqrt(), 0.0, nv);
let l = Vec3::new(0.0, (1.0 - nl * nl).sqrt(), nl);
assert!((s.brdf(Vec3::Z, v, l).unwrap() - color / PI).len() < 1e-14);
}
}
}
for metallic in [0.0, 0.25, 0.5, 1.0] {
let s = OpaqueSurface::new(color, metallic, 0.5, 1.0).unwrap();
for c in [0.0_f64, 0.2, 0.9, 1.0] {
let conductor = color + (Vec3::new(1., 1., 1.) - color) * (1.0 - c).powi(5);
assert!((s.specular_fresnel(c) - conductor * metallic).len() < 1e-14);
}
}
}
#[test]
fn srgb_conversion_includes_the_linear_toe() {
assert_eq!(srgb8_to_linear(0), 0.0);
+194
View File
@@ -0,0 +1,194 @@
//! Bounded optical data and original f64 dielectric transport oracles. Units are mm.
use solidcraft_geom::Vec3;
use crate::surface::{SurfaceError, unit_direction};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transmission {
absorption_per_mm: Vec3,
thin_wall_thickness_mm: Option<f64>,
shadow_transmission: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransmissionError {
Absorption,
Distance,
Thickness,
Angle,
Ior,
Direction,
}
impl std::fmt::Display for TransmissionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Absorption => "absorption_per_mm must be finite RGB coefficients in [0, 100] per mm",
Self::Distance => "geometric absorption distance must be finite and nonnegative",
Self::Thickness => "thin-sheet thickness must be finite and in [0, 10000] mm",
Self::Angle => "incident cosine must be finite and in [0, 1]",
Self::Ior => "medium IOR must be finite and in [1, 3]",
Self::Direction => "incident direction must face the finite nonzero interface normal",
})
}
}
impl std::error::Error for TransmissionError {}
impl Transmission {
pub fn new(absorption_per_mm: Vec3, thin_wall_thickness_mm: Option<f64>) -> Result<Self, TransmissionError> {
if !absorption_per_mm.is_finite()
|| ![absorption_per_mm.x, absorption_per_mm.y, absorption_per_mm.z].into_iter().all(|v| (0.0..=100.0).contains(&v))
{
return Err(TransmissionError::Absorption);
}
if thin_wall_thickness_mm.is_some_and(|v| !v.is_finite() || !(0.0..=10000.0).contains(&v)) {
return Err(TransmissionError::Thickness);
}
Ok(Self { absorption_per_mm, thin_wall_thickness_mm, shadow_transmission: thin_wall_thickness_mm.is_some() })
}
/// Thin sheets enable straight shadow transmission by default; solids opt in.
pub fn with_shadow_transmission(mut self, enabled: bool) -> Self {
self.shadow_transmission = enabled;
self
}
pub fn shadow_transmission(self) -> bool {
self.shadow_transmission
}
pub fn thin_wall_thickness_mm(self) -> Option<f64> {
self.thin_wall_thickness_mm
}
pub fn extinction(self) -> Vec3 {
self.absorption_per_mm
}
pub fn attenuation(self, distance_mm: f64) -> Result<Vec3, TransmissionError> {
if !distance_mm.is_finite() || distance_mm < 0.0 {
return Err(TransmissionError::Distance);
}
let s = self.extinction();
Ok(Vec3::new((-s.x * distance_mm).exp(), (-s.y * distance_mm).exp(), (-s.z * distance_mm).exp()))
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Fresnel {
pub s: f64,
pub p: f64,
/// None at total internal reflection.
pub transmitted_cosine: Option<f64>,
}
impl Fresnel {
pub fn reflectance(self) -> f64 {
(self.s + self.p) * 0.5
}
}
/// Exact unpolarized power reflectance at a lossless dielectric interface.
pub fn fresnel(cosine: f64, incident_ior: f64, transmitted_ior: f64) -> Result<Fresnel, TransmissionError> {
if !cosine.is_finite() || !(0.0..=1.0).contains(&cosine) {
return Err(TransmissionError::Angle);
}
if [incident_ior, transmitted_ior].into_iter().any(|v| !v.is_finite() || !(1.0..=3.0).contains(&v)) {
return Err(TransmissionError::Ior);
}
if incident_ior == transmitted_ior {
return Ok(Fresnel { s: 0.0, p: 0.0, transmitted_cosine: Some(cosine) });
}
let eta = incident_ior / transmitted_ior;
let sin2 = eta * eta * (1.0 - cosine * cosine).max(0.0);
if sin2 >= 1.0 {
return Ok(Fresnel { s: 1.0, p: 1.0, transmitted_cosine: None });
}
let ct = (1.0 - sin2).sqrt();
let rs = (incident_ior * cosine - transmitted_ior * ct) / (incident_ior * cosine + transmitted_ior * ct);
let rp = (transmitted_ior * cosine - incident_ior * ct) / (transmitted_ior * cosine + incident_ior * ct);
Ok(Fresnel { s: rs * rs, p: rp * rp, transmitted_cosine: Some(ct) })
}
/// Incident direction points toward the surface; normal points into the incident medium.
pub fn refract(incident: Vec3, normal: Vec3, incident_ior: f64, transmitted_ior: f64) -> Result<Option<Vec3>, TransmissionError> {
let direction_error = |_: SurfaceError| TransmissionError::Direction;
let d = unit_direction(incident).map_err(direction_error)?;
let n = unit_direction(normal).map_err(direction_error)?;
let cosine = -d.dot(n);
if cosine < 0.0 {
return Err(TransmissionError::Direction);
}
let f = fresnel(cosine.min(1.0), incident_ior, transmitted_ior)?;
Ok(f.transmitted_cosine.map(|ct| {
let eta = incident_ior / transmitted_ior;
d * eta + n * (eta * cosine - ct)
}))
}
/// Incoherent parallel slab, including all repeated internal reflections. The two
/// external media have the same IOR. Returns linear RGB (reflectance, transmittance).
pub fn slab(
transmission: Transmission,
thickness_mm: f64,
cosine: f64,
outside_ior: f64,
inside_ior: f64,
) -> Result<(Vec3, Vec3), TransmissionError> {
if !thickness_mm.is_finite() || !(0.0..=10000.0).contains(&thickness_mm) {
return Err(TransmissionError::Thickness);
}
let f = fresnel(cosine, outside_ior, inside_ior)?;
let Some(ct) = f.transmitted_cosine.filter(|v| *v > 0.0) else {
return Ok((Vec3::new(1.0, 1.0, 1.0), Vec3::ZERO));
};
let a = transmission.attenuation(thickness_mm / ct)?;
let channel = |r: f64, a: f64| {
let denominator = 1.0 - r * r * a * a;
if denominator <= 0.0 {
return (1.0, 0.0);
}
(r + (1.0 - r).powi(2) * r * a * a / denominator, (1.0 - r).powi(2) * a / denominator)
};
// A parallel slab preserves polarization. Sum each geometric series before
// averaging s/p, rather than repeatedly applying the unpolarized mean.
let average = |a: f64| {
let (rs, ts) = channel(f.s, a);
let (rp, tp) = channel(f.p, a);
((rs + rp) * 0.5, (ts + tp) * 0.5)
};
let (rx, tx) = average(a.x);
let (ry, ty) = average(a.y);
let (rz, tz) = average(a.z);
Ok((Vec3::new(rx, ry, rz), Vec3::new(tx, ty, tz)))
}
/// Explicit straight-through solid shadow approximation. Both interface losses
/// use reciprocal external-angle Fresnel; `distance_mm` is the actual geometric
/// entry/exit interval. This omits refraction and repeated internal reflections.
pub fn solid_shadow(transmission: Transmission, distance_mm: f64, entry_cosine: f64, exit_cosine: f64, ior: f64) -> Result<Vec3, TransmissionError> {
let entry = fresnel(entry_cosine, 1.0, ior)?.reflectance();
let exit = fresnel(exit_cosine, 1.0, ior)?.reflectance();
Ok(transmission.attenuation(distance_mm)? * ((1.0 - entry) * (1.0 - exit)))
}
#[cfg(test)]
mod shadow_tests {
use super::*;
#[test]
fn defaults_and_reciprocal_solid_shadow_match_analytic_beer() {
let sigma = Vec3::new(-0.5_f64.ln(), -0.8_f64.ln(), 0.0);
let solid = Transmission::new(sigma, None).unwrap();
let sheet = Transmission::new(sigma, Some(1.0)).unwrap();
assert!(!solid.shadow_transmission());
assert!(sheet.shadow_transmission());
assert!(solid.with_shadow_transmission(true).shadow_transmission());
assert!(!sheet.with_shadow_transmission(false).shadow_transmission());
assert!((solid_shadow(solid, 1.0, 1.0, 1.0, 1.5).unwrap() - Vec3::new(0.5, 0.8, 1.0) * 0.96 * 0.96).len() < 1e-14);
assert_eq!(solid_shadow(solid, 1.0, 0.2, 0.8, 1.5).unwrap(), solid_shadow(solid, 1.0, 0.8, 0.2, 1.5).unwrap());
for ior in [1.0, 1.5, 3.0] {
for cosine in [0.0, 0.02, 0.5, 1.0] {
let shadow = solid_shadow(solid, 1.0, cosine, cosine, ior).unwrap();
assert!(shadow.is_finite() && shadow.x >= 0.0 && shadow.z <= 1.0);
}
}
assert!(solid_shadow(solid, f64::NAN, 1.0, 1.0, 1.5).is_err());
}
}
#[cfg(test)]
#[path = "dielectric_oracle_tests.rs"]
mod tests;
+75 -15
View File
@@ -60,6 +60,7 @@ pub fn library(app: &mut SolveApp) -> Vec<Look> {
let list = v.get("appearances").or(v.get("library")).unwrap_or(&v).as_array()?.clone();
let looks: Vec<Look> = list
.iter()
.filter(|entry| entry.get("available").and_then(Value::as_bool) != Some(false))
.filter_map(|x| {
Some(Look {
name: x.get("name")?.as_str()?.to_string(),
@@ -160,7 +161,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
}
/// The command giving `targets` (bodies or faces) a look.
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>) -> Option<Value> {
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>, pbr: Option<PbrParameters>) -> Option<Value> {
let bodies: Vec<String> = targets.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
let faces: Vec<Value> = targets
.iter()
@@ -184,8 +185,9 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str
}
}
p["opacity"] = json!(opacity);
p["pbr"] = json!(pbr);
if let Some(n) = look {
p["appearance"] = json!(n);
p["name"] = json!(n);
}
Some(p)
}
@@ -205,15 +207,12 @@ pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
}
pub(super) fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
let mut p = if let Some(component) = &k.component {
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
// Every path applies its complete owned look, never re-resolves its label.
if let Some(component) = &k.component {
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "name": k.look, "pbr": k.pbr}))
} else {
apply_params(items, k.color, k.opacity, k.look.as_deref())
}?;
if let Some(pbr) = k.pbr {
p["pbr"] = json!(pbr);
apply_params(items, k.color, k.opacity, k.look.as_deref(), k.pbr)
}
Some(p)
}
/// Removing an override restores the inherited component or material appearance.
@@ -221,6 +220,7 @@ fn clear_params(k: &Ap, items: &[Sel]) -> Option<Value> {
let mut p = selected_params(k, items)?;
if let Some(obj) = p.as_object_mut() {
obj.remove("appearance");
obj.remove("name");
obj.remove("color");
obj.remove("opacity");
obj.remove("pbr");
@@ -444,10 +444,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
k.pbr = look.pbr;
k.look = Some(look.name.clone());
k.remove_override = false;
if let Some(mut p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
if let Some(pbr) = look.pbr {
p["pbr"] = json!(pbr);
}
if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref(), look.pbr) {
k.note = Some(match app.run("appearance.assign", p.clone()) {
Ok(_) => {
k.applied = Some(p);
@@ -564,7 +561,7 @@ mod tests {
a.viewport.hover = None;
a.viewport.hover_bodies = vec!["Body2".into()];
assert_eq!(drop_target(&a, false), vec![Sel::Body { name: "Body2".into() }]);
let face = apply_params(&[Sel::Face { body: "Body1".into(), index: 0, point: p }], [1, 2, 3], 0.5, None).unwrap();
let face = apply_params(&[Sel::Face { body: "Body1".into(), index: 0, point: p }], [1, 2, 3], 0.5, None, None).unwrap();
assert_eq!(face["faces"][0]["body"], "Body1");
assert_eq!(face["opacity"], 0.5);
assert!(!library(&mut a).is_empty());
@@ -609,7 +606,7 @@ mod tests {
let Kind::Appearance(k) = &a.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
assert_eq!(k.opacity, 0.25);
assert_eq!(k.look.as_deref(), Some("Tinted glass"));
let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name)).unwrap();
let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name), look.pbr).unwrap();
a.run("appearance.assign", p).unwrap();
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
}
@@ -746,4 +743,67 @@ mod tests {
assert!(crate::scenario::handle_at(handle).is_some(), "missing {handle}");
}
}
#[test]
fn selectable_library_excludes_unavailable_starter_glass_recipes() {
let mut h = crate::scenario::Harness::new();
let listed = h.app.session.execute("appearance.library", &json!({})).unwrap();
let rows = listed["appearances"].as_array().unwrap();
let available = rows.iter().filter(|row| row.get("available").and_then(Value::as_bool) != Some(false)).count();
let selectable = library(&mut h.app);
assert_eq!(selectable.len(), available);
for preset in &solidcraft_engine::doc::appearance_pack::STARTER_PACK {
assert_eq!(selectable.iter().any(|look| look.name == preset.name), preset.is_opaque());
}
}
#[test]
fn applying_and_clearing_owned_recipe_labels_never_resolves_the_recipe() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"body_name":"Owned","length":10,"width":10,"height":10})).unwrap();
for name in ["starter.glass.clear-soda-lime", "Glass Recipe - Low Iron Clear", "Metal - Warm Reflective Gold"] {
h.app.run("appearance.assign", json!({"bodies":["Owned"],"name":name,"color":[31,61,91],"pbr":null})).unwrap();
let original = h.app.session.doc.appearances.bodies["Owned"].clone();
h.app.session.selection = vec![Sel::Body { name: "Owned".into() }];
h.app.start("appearance.assign");
let dialog = h.app.dialog.as_ref().unwrap();
let Kind::Appearance(state) = &dialog.kind else { panic!("appearance editor expected") };
let params = selected_params(state, &dialog.inputs[0].items).unwrap();
let clear = clear_params(state, &dialog.inputs[0].items).unwrap();
assert_eq!(params["name"], name);
assert!(params.get("appearance").is_none());
assert_eq!(params["pbr"], Value::Null);
h.app.run("appearance.assign", params).unwrap();
assert_eq!(h.app.session.doc.appearances.bodies["Owned"], original);
assert!(clear.get("name").is_none() && clear.get("appearance").is_none());
h.app.run("appearance.assign", clear).unwrap();
assert!(!h.app.session.doc.appearances.bodies.contains_key("Owned"));
}
}
#[test]
fn dropped_scalar_and_pbr_requests_match_ok_without_an_extra_undo() {
for response in [None, Some(PbrParameters::new(0.7, 0.25, 1.5).unwrap())] {
let mut a = app();
a.session.selection = vec![Sel::Body { name: "Body1".into() }];
a.start("appearance.assign");
let mut dialog = a.dialog.clone().unwrap();
let Kind::Appearance(state) = &mut dialog.kind else { panic!("appearance editor") };
state.look = Some("Owned swatch".into());
state.color = [31, 61, 91];
state.opacity = 1.0;
state.pbr = response;
let params = apply_params(&dialog.inputs[0].items, state.color, state.opacity, state.look.as_deref(), state.pbr).unwrap();
assert!(params.get("pbr").is_some(), "the complete owned response is explicit");
assert_eq!(params["pbr"], json!(response));
let original = a.session.doc.clone();
let undo = a.session.undo.len();
a.run("appearance.assign", params.clone()).unwrap();
state.applied = Some(params);
assert_eq!(a.session.undo.len(), undo + 1);
assert!(commands(state, &dialog.inputs).is_empty(), "OK after a drop is not another edit");
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.doc, original);
}
}
}
+1 -1
View File
@@ -235,8 +235,8 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
Ok(_) => {
if let Definition::Appearance(look) = entry.definition {
k.color = look.color;
k.pbr = None;
k.opacity = look.opacity;
k.pbr = look.pbr;
k.look = Some(look.name.clone());
k.remove_override = false;
k.applied = selected_params(k, picked);
+43 -22
View File
@@ -4,21 +4,32 @@ use solidcraft_engine::{Sel, Session};
#[test]
fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
clicked_catalog(None, "solidcraft-library/1").unwrap();
}
#[test]
fn catalog_clicked_pbr_load_apply_export_and_ok_retains_the_optical_response() {
clicked_catalog(Some(solidcraft_engine::doc::pbr_appearance::PbrParameters::new(1., 0.3, 1.5).unwrap()), "solidcraft-library/2").unwrap();
}
#[test]
fn catalog_clicked_rich_entry_without_pbr_clears_the_old_optical_response() {
clicked_catalog(None, "solidcraft-library/2").unwrap();
}
fn clicked_catalog(pbr: Option<solidcraft_engine::doc::pbr_appearance::PbrParameters>, format: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1400}));
let path = h.dir.join("source-library.json").to_string_lossy().into_owned();
let mut source = Session::default();
source.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
source.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
source.execute("library.export", &json!({"path":path})).unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app
.run(
"appearance.assign",
json!({"bodies":["Body1"],"color":[20,20,20],
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
)
.unwrap();
source.execute("solid.box", &json!({"length":10,"width":10,"height":10}))?;
source.execute(
"appearance.assign",
&json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":if pbr.is_some(){1.0}else{0.8},"pbr":pbr}),
)?;
source.execute("library.export", &json!({"path":path,"format":format}))?;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10}))?;
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[20,20,20],"pbr":{"metallic":1.0,"roughness":0.6,"dielectric_ior":1.7}}))?;
h.app.session.mark_saved();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
@@ -29,7 +40,7 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
}
h.frames(3);
let undo = h.app.session.undo.len();
h.step(&json!({"widget":"Load library"})).unwrap();
h.step(&json!({"widget":"Load library"}))?;
for handle in [
"library:path",
"library:load",
@@ -49,26 +60,36 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
{
assert!(h.widget(message).is_some(), "missing {message}");
}
h.step(&json!({"click":{"handle":"library:search"}})).unwrap();
h.step(&json!({"text":"no such appearance"})).unwrap();
h.step(&json!({"click":{"handle":"library:search"}}))?;
h.step(&json!({"text":"no such appearance"}))?;
assert!(h.widget("No matching appearance presets.").is_some());
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"key":"Backspace"})).unwrap();
h.step(&json!({"click":{"handle":"library:entry:0"}})).unwrap();
h.step(&json!({"widget":"Apply library appearance"})).unwrap();
h.step(&json!({"key":"A","cmd":true}))?;
h.step(&json!({"key":"Backspace"}))?;
h.step(&json!({"click":{"handle":"library:entry:0"}}))?;
h.step(&json!({"widget":"Apply library appearance"}))?;
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].name, "Warm oak colour");
assert!(h.app.session.doc.appearances.bodies["Body1"].pbr.is_none());
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].pbr, pbr);
if let Some(dialog) = &h.app.dialog
&& let Kind::Appearance(state) = &dialog.kind
{
assert_eq!(state.pbr, pbr);
}
assert_eq!(h.app.session.undo.len(), undo + 1);
let revision = h.app.session.revision;
h.step(&json!({"widget":"Export design library..."})).unwrap();
h.step(&json!({"widget":"Export design library..."}))?;
let exported = h.dir.join("my-library.solidcraft-library.json");
let catalog = Catalog::read(&exported.to_string_lossy()).unwrap();
let catalog = Catalog::read(&exported.to_string_lossy())?;
assert_eq!(catalog.entries.len(), 1);
assert_eq!(catalog.entries[0].definition.name(), "Warm oak colour");
if let Definition::Appearance(look) = &catalog.entries[0].definition {
assert_eq!(look.pbr, pbr);
}
assert_eq!(h.app.session.revision, revision);
h.step(&json!({"widget":"OK"})).unwrap();
h.step(&json!({"widget":"OK"}))?;
assert!(h.app.dialog.is_none());
assert_eq!(h.app.session.undo.len(), undo + 1);
Ok(())
}
fn appearance_fixture() -> Result<(Harness, Arc<()>), String> {
+38 -3
View File
@@ -117,11 +117,30 @@ impl Locale {
/// Translate source labels from engine/UI tables, preserving unknown labels.
/// Also accepts stable IDs while table helpers migrate away from source text.
pub fn label(self, label: &str) -> &str {
static LABEL_IDS: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();
// Stable IDs are authoritative. An untranslated legacy ID identical to its
// English caption can still use another translated ID for that source label.
if ENGLISH.messages().get(label).is_some_and(|text| text != label || self.0.messages().contains_key(label)) {
return self.text(label);
}
static LABEL_IDS: OnceLock<BTreeMap<&'static str, Vec<&'static str>>> = OnceLock::new();
let ids = LABEL_IDS.get_or_init(|| {
ENGLISH.messages().iter().filter(|(_, text)| !text.contains('{')).map(|(id, text)| (text.as_str(), id.as_str())).collect()
let mut ids: BTreeMap<_, Vec<_>> = BTreeMap::new();
for (id, text) in ENGLISH.messages().iter().filter(|(_, text)| !text.contains('{')) {
ids.entry(text.as_str()).or_default().push(id.as_str());
}
ids
});
ids.get(label).map_or_else(|| self.text(label), |id| self.text(id))
if let Some(candidates) = ids.get(label) {
// Preserve the old last-ID preference among available translations. A new
// English-only alias must not hide a translation that already exists here.
if let Some(text) = candidates.iter().rev().find_map(|id| self.0.messages().get(*id)) {
return text;
}
if let Some(id) = candidates.last() {
return self.text(id);
}
}
self.text(label)
}
pub fn format(self, id: &str, args: &[(&str, &str)]) -> String {
@@ -248,6 +267,22 @@ pub fn tr_name(text: &str) -> String {
mod tests {
use super::*;
#[test]
fn alias_resolution_regression() {
static LEGACY: Catalog = Catalog::new("test-legacy", "Test", r#"{"Pitch":"legacy pitch"}"#);
static ALIAS: Catalog = Catalog::new("test-alias", "Test", r#"{"coupling.pitch":"mechanical pitch"}"#);
assert_eq!(ENGLISH.messages().get("Pitch").map(String::as_str), Some("Pitch"));
assert_eq!(ENGLISH.messages().get("coupling.pitch").map(String::as_str), Some("Pitch"));
let legacy = Locale(&LEGACY);
assert_eq!(legacy.label("Pitch"), "legacy pitch");
assert_eq!(legacy.label("coupling.pitch"), "Pitch");
let alias = Locale(&ALIAS);
assert_eq!(alias.label("Pitch"), "mechanical pitch");
assert_eq!(alias.label("coupling.pitch"), "mechanical pitch");
assert_eq!(alias.label("original unknown caption"), "original unknown caption");
}
/// English first, then by id, so adding a locale edits only its own line.
#[test]
fn locales_are_listed_english_first_then_by_id() {
+10
View File
@@ -696,6 +696,12 @@ impl SolveApp {
None
}
};
#[cfg(not(target_arch = "wasm32"))]
{
let adapter = rs.adapter.clone();
let key = render_workspace::adapter_key(&adapter.get_info());
self.render_workspace.defer_path_host(key, move || solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string()));
}
render_workspace::sync_backend(self);
self.msaa_supported = rs.msaa_supported.clone();
let info = rs.adapter.get_info();
@@ -722,6 +728,7 @@ impl SolveApp {
"ground_shadow": self.ui.ground_shadow,
"agent_cursor": self.ui.agent_cursor,
"preferences": self.preferences,
"gpu_path_setup_seconds": self.render_workspace.setup_seconds,
})
.to_string()
}
@@ -734,6 +741,9 @@ impl SolveApp {
if let Some(d) = flag("dark") {
self.ui.dark = d;
}
if let Some(t) = v.get("gpu_path_setup_seconds") {
self.render_workspace.setup_seconds = render_workspace::load_setup_seconds(t);
}
if let Some(g) = flag("grid") {
self.ui.show_grid = g;
}
+272 -9
View File
@@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex};
use egui::{Color32, Sense};
use serde_json::json;
use solidcraft_engine::doc::render_settings::RenderSetup;
use solidcraft_engine::render::job::ImageHost;
use solidcraft_engine::render::{
Availability, Camera, ExecutorDescriptor, ExecutorId, ProductScene, RenderLimits, RenderMode, RenderOperation, RenderProfile, RenderRequest,
ScratchLayout,
@@ -21,8 +22,17 @@ const MAX_GEOMETRY_BYTES: usize = 128 * 1024 * 1024;
#[path = "render_workspace_tests.rs"]
mod tests;
type PathFactory = Box<dyn FnOnce() -> Result<ImageHost, String> + Send>;
pub struct State {
pending_setup: Option<PathFactory>,
pub host: Option<ExecutorDescriptor>,
pub path_host: PathHost,
/// How long the last path tracer setup took, in seconds, per viewport adapter
/// ([`adapter_key`]); kept in the preferences to estimate the next one.
pub setup_seconds: std::collections::BTreeMap<String, f64>,
/// The viewport adapter the current setup runs on.
pub setup_key: Option<String>,
pub image_host: Option<solidcraft_engine::render::job::CpuHost>,
pub view: ViewState,
revision: u64,
@@ -31,6 +41,121 @@ pub struct State {
key: Option<u64>,
scene_signature: Option<(u64, usize, u64)>,
scene: Option<ProductScene>,
image: Option<(solidcraft_engine::render::job::JobId, Result<egui::TextureHandle, String>)>,
}
/// Application-wide setup, retained across document changes. The worker owns a
/// cloned adapter and initializes the job device; the UI receives the finished host.
#[derive(Default)]
pub enum PathHost {
#[default]
NotStarted,
/// The worker's answer, and when it started: shader compilation can take minutes.
Preparing(std::sync::mpsc::Receiver<Result<ImageHost, String>>, std::time::Instant),
Ready(ImageHost),
Failed(String),
}
impl PathHost {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn start(&mut self, create: impl FnOnce() -> Result<ImageHost, String> + Send + 'static) {
if !matches!(self, Self::NotStarted) {
return;
}
let (tx, rx) = std::sync::mpsc::channel();
// Dropping the handle detaches: neither startup nor shutdown joins a driver
// that may spend minutes compiling or warming up its pipelines.
*self = match std::thread::Builder::new().name("gpu-path-setup".into()).spawn(move || {
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(create)).unwrap_or_else(|_| Err("GPU path tracer setup panicked".into()));
let _ = tx.send(result);
}) {
Ok(_) => Self::Preparing(rx, std::time::Instant::now()),
Err(e) => Self::failed(format!("could not start setup worker: {e}")),
};
}
fn failed(message: String) -> Self {
log::warn!("progressive render unavailable: {message}");
Self::Failed(message)
}
/// Take the worker's answer if it has come; returns how long a successful setup took.
fn poll(&mut self) -> Option<std::time::Duration> {
let Self::Preparing(rx, started) = self else { return None };
let took = started.elapsed();
*self = match rx.try_recv() {
Ok(Ok(host)) => Self::Ready(host),
Ok(Err(error)) => Self::failed(error),
Err(std::sync::mpsc::TryRecvError::Empty) => return None,
Err(std::sync::mpsc::TryRecvError::Disconnected) => Self::failed("setup worker stopped without returning a host".into()),
};
matches!(self, Self::Ready(_)).then_some(took)
}
}
/// The samples slider's top end: 2^12 = 4096 samples per pixel.
const MAX_SAMPLES_EXPONENT: u32 = 12;
/// Setup times kept per adapter, at most.
const MAX_SETUP_TIMES: usize = 16;
/// The key [`State::setup_seconds`] files an adapter's setup time under: a driver update
/// usually empties the driver's shader cache, so the driver is part of it.
pub fn adapter_key(info: &egui_wgpu::wgpu::AdapterInfo) -> String {
format!("{} | {:?} | {} {}", info.name.trim(), info.backend, info.driver.trim(), info.driver_info.trim())
}
/// Remember how long the setup on `key` took, for the next start's estimate.
fn record_setup(state: &mut State, took: std::time::Duration) {
let Some(key) = state.setup_key.clone() else { return };
if state.setup_seconds.len() >= MAX_SETUP_TIMES && !state.setup_seconds.contains_key(&key) {
return;
}
state.setup_seconds.insert(key, took.as_secs_f64());
}
/// Setup times from the preferences: finite, non-negative and at most a day each.
pub fn load_setup_seconds(value: &serde_json::Value) -> std::collections::BTreeMap<String, f64> {
value
.as_object()
.into_iter()
.flatten()
.filter_map(|(k, v)| Some((k.clone(), v.as_f64().filter(|s| s.is_finite() && (0.0..=86_400.0).contains(s))?)))
.take(MAX_SETUP_TIMES)
.collect()
}
/// Minutes and seconds, as `1:05`.
fn mmss(d: std::time::Duration) -> String {
let s = d.as_secs();
format!("{}:{:02}", s / 60, s % 60)
}
/// The path tracer's setup as it goes: a progress bar estimated from the last setup on this
/// adapter, else a spinner with the time so far. Drivers report no progress of their own.
fn setup_progress(ui: &mut egui::Ui, app: &SolveApp, started: std::time::Instant) {
let elapsed = started.elapsed();
let expected = app.render_workspace.setup_key.as_ref().and_then(|k| app.render_workspace.setup_seconds.get(k)).copied();
ui.label(app.tr("render.gpu_path_preparing"));
match expected.map(|s| std::time::Duration::from_secs_f64(s.max(1.0))) {
Some(expected) if elapsed < expected.mul_f64(1.5) => {
let fraction = (elapsed.as_secs_f32() / expected.as_secs_f32()).min(0.99);
crate::ui_kit::progress(ui, "gpu_path_setup", fraction);
ui.label(format!("{} / ~{}", mmss(elapsed), mmss(expected)));
// The bar moves with time, so keep drawing while it is shown.
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
}
_ => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(mmss(elapsed));
});
if expected.is_none() {
ui.small(app.tr("render.gpu_path_first_setup"));
}
}
}
}
/// Small per-document editor state; GPU uploads and host capabilities stay outside tabs.
@@ -42,12 +167,24 @@ pub struct ViewState {
job: Option<solidcraft_engine::render::job::JobId>,
completed: Option<solidcraft_engine::render::job::JobId>,
transparent: bool,
progressive: bool,
samples: u32,
overwrite: bool,
}
impl Default for ViewState {
fn default() -> Self {
Self { name: "Studio".into(), draft: RenderSetup::default(), dirty: false, job: None, completed: None, transparent: false, overwrite: false }
Self {
name: "Studio".into(),
draft: RenderSetup::default(),
dirty: false,
job: None,
completed: None,
transparent: false,
progressive: false,
samples: 64,
overwrite: false,
}
}
}
@@ -55,6 +192,10 @@ impl Default for State {
fn default() -> Self {
Self {
host: None,
path_host: PathHost::default(),
pending_setup: None,
setup_seconds: Default::default(),
setup_key: None,
image_host: None,
view: Default::default(),
revision: u64::MAX,
@@ -63,6 +204,29 @@ impl Default for State {
key: None,
scene_signature: None,
scene: None,
image: None,
}
}
}
impl State {
/// Retain the selected viewport adapter, but do not open another device or
/// compile shaders until Render is used. A later viewport installation (for
/// example safe-mode fallback) replaces only a not-yet-started factory.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn defer_path_host(&mut self, key: String, create: impl FnOnce() -> Result<ImageHost, String> + Send + 'static) {
if matches!(self.path_host, PathHost::NotStarted) {
self.setup_key = Some(key);
self.pending_setup = Some(Box::new(create));
}
}
#[cfg(not(target_arch = "wasm32"))]
fn start_deferred_path_host(&mut self) {
if matches!(self.path_host, PathHost::NotStarted)
&& let Some(create) = self.pending_setup.take()
{
self.path_host.start(create);
}
}
}
@@ -99,6 +263,20 @@ pub fn host_executor(max_dimension: u32, depth: bool, samples: u32) -> Result<Ex
}
pub fn sync_backend(app: &mut SolveApp) {
#[cfg(not(target_arch = "wasm32"))]
if active(app) {
app.render_workspace.start_deferred_path_host();
}
if let Some(took) = app.render_workspace.path_host.poll() {
record_setup(&mut app.render_workspace, took);
}
if let PathHost::Ready(host) = &app.render_workspace.path_host
&& let Err(e) = app.session.register_render_job_host(host.clone())
{
app.render_workspace.path_host = PathHost::failed(e.to_string());
app.set_status(e.to_string(), true);
}
if app.session.render_job_host().is_none()
&& let Some(host) = &app.render_workspace.image_host
&& let Err(e) = app.session.set_render_job_host(Some(host.clone()))
@@ -116,6 +294,10 @@ pub fn sync_backend(app: &mut SolveApp) {
/// Cooperative work belongs to the frame loop, never to a status/control request. Keep
/// scheduling frames even after leaving Render so a browser job can finish in Design.
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
if matches!(app.render_workspace.path_host, PathHost::Preparing(..)) {
// Poll even in Design or an idle window, without continuously redrawing.
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
if let Some(host) = &app.render_workspace.image_host {
match host.pump(1024) {
Ok(true) => ctx.request_repaint(),
@@ -129,7 +311,11 @@ pub fn reset_document(app: &mut SolveApp) {
app.ui.ribbon_context = None;
let host = app.render_workspace.host.clone();
let image_host = app.render_workspace.image_host.clone();
app.render_workspace = State { host, image_host, ..Default::default() };
let path_host = std::mem::take(&mut app.render_workspace.path_host);
let setup_seconds = std::mem::take(&mut app.render_workspace.setup_seconds);
let setup_key = app.render_workspace.setup_key.take();
let pending_setup = app.render_workspace.pending_setup.take();
app.render_workspace = State { host, image_host, path_host, setup_seconds, setup_key, pending_setup, ..Default::default() };
if active(app) {
app.ui.tab = "MODEL".into();
}
@@ -157,6 +343,8 @@ pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> {
app.cam.fit(&scene.bounds);
}
}
#[cfg(not(target_arch = "wasm32"))]
app.render_workspace.start_deferred_path_host();
} else {
if let Some((camera, perspective)) = app.render_workspace.design_camera.take() {
app.cam = camera;
@@ -184,7 +372,7 @@ pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading(app.tr("cmd.render_settings_get"));
ui.label(app.tr("render.preview"));
ui.small(app.tr("render.opaque_preview_help"));
ui.add(egui::Label::new(egui::RichText::new(app.tr("render.opaque_preview_help")).small()).wrap());
ui.separator();
let names: Vec<_> = app.session.doc.render.setups.keys().cloned().collect();
let old_name = app.render_workspace.view.name.clone();
@@ -266,7 +454,7 @@ pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.heading(app.tr("render.image_title"));
ui.small(app.tr("render.opaque_image_summary"));
ui.small(app.tr(if app.render_workspace.view.progressive { "render.progressive_image_summary" } else { "render.opaque_image_summary" }));
ui.horizontal(|ui| {
ui.label(app.tr("render.image_width"));
if crate::ui_kit::number_field(ui, &mut app.render_workspace.view.draft.width, 1..=4096, 1.0, "").changed() {
@@ -289,11 +477,32 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
app.render_workspace.view.dirty = true;
}
}
match &app.render_workspace.path_host {
PathHost::Preparing(_, started) => setup_progress(ui, app, *started),
PathHost::Failed(error) => {
ui.add(egui::Label::new(format!("{}: {error}", app.tr("render.gpu_path_failed"))).wrap());
}
_ => {}
}
if matches!(app.render_workspace.path_host, PathHost::Ready(_)) {
let label = app.tr("render.image_progressive").to_string();
crate::ui_kit::checkbox(ui, &label, &mut app.render_workspace.view.progressive);
if app.render_workspace.view.progressive {
ui.label(app.tr("render.image_samples"));
// Powers of two from 1 to 4096 samples per pixel; requests from elsewhere keep any count.
let samples = &mut app.render_workspace.view.samples;
let mut exponent = (*samples).max(1).ilog2().min(MAX_SAMPLES_EXPONENT);
let slider = crate::ui_kit::Slider { integer: true, format: |e| format!("{}", 1u32 << (e as u32).min(31)), ..Default::default() };
if crate::ui_kit::slider(ui, &mut exponent, 0..=MAX_SAMPLES_EXPONENT, slider).changed() {
*samples = 1 << exponent.min(MAX_SAMPLES_EXPONENT);
}
}
}
let transparent_label = app.tr("render.image_transparent").to_string();
crate::ui_kit::checkbox(ui, &transparent_label, &mut app.render_workspace.view.transparent);
if crate::ui_kit::button(ui, app.tr("cmd.render_preflight"), crate::ui_kit::Button::Normal).clicked() {
let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
if app.run("render.preflight", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})).is_ok() {
if app.run("render.preflight", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent,"progressive":app.render_workspace.view.progressive,"samples":app.render_workspace.view.progressive.then_some(app.render_workspace.view.samples)})).is_ok() {
app.set_status(app.tr("render.image_supported").to_string(), false);
}
}
@@ -318,7 +527,7 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
app.render_workspace.view.completed = Some(job);
}
if let Some(message) = status["error"]["message"].as_str() {
ui.label(message);
ui.add(egui::Label::new(message).wrap());
if status["error"]["code"] == "limit_exceeded" {
ui.small(app.tr("render.image_reduce"));
}
@@ -339,13 +548,13 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
Err(e) => {
ui.label(e.to_string());
ui.add(egui::Label::new(e.to_string()).wrap());
}
}
}
if crate::ui_kit::button(ui, app.tr("render.image_start"), crate::ui_kit::Button::Enabled(!busy)).clicked() {
let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
if let Ok(reply) = app.run("render.start", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})) {
if let Ok(reply) = app.run("render.start", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent,"progressive":app.render_workspace.view.progressive,"samples":app.render_workspace.view.progressive.then_some(app.render_workspace.view.samples)})) {
app.render_workspace.view.job = serde_json::from_value(reply["job"].clone()).ok();
ui.ctx().request_repaint();
}
@@ -358,6 +567,56 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
{
export_image(app, job, app.render_workspace.view.overwrite);
}
completed_image(app, ui);
}
/// The job currently publishes a PNG only after encoding completes. Decode/upload once per
/// completed job, not once per UI frame; keep the last exportable image during a new render.
fn completed_image(app: &mut SolveApp, ui: &mut egui::Ui) {
let Some(job) = app.render_workspace.view.completed else {
app.render_workspace.image = None;
return;
};
if app.render_workspace.image.as_ref().is_none_or(|(old, _)| *old != job) {
let result = (|| -> Result<egui::TextureHandle, String> {
let handle = app.session.render_job_handle(job).map_err(|e| e.to_string())?;
let frame = handle.frame().map_err(|e| e.to_string())?.ok_or("render has no completed image")?;
let image = decode_image(frame.png(), frame.info.width, frame.info.height)?;
Ok(ui.ctx().load_texture("sc_render_image", image, egui::TextureOptions::LINEAR))
})();
// Cache errors too, so a bad frame cannot repeatedly allocate or decode on repaint.
app.render_workspace.image = Some((job, result));
}
if let Some((_, image)) = &app.render_workspace.image {
match image {
Ok(texture) => {
ui.add(egui::Image::new(texture).max_size(egui::vec2(ui.available_width(), 240.0)).shrink_to_fit())
.on_hover_text(app.tr("render.image_completed"));
}
Err(error) => {
ui.add(egui::Label::new(error).wrap());
}
}
}
}
fn decode_image(png: &[u8], width: u32, height: u32) -> Result<egui::ColorImage, String> {
let size = (u64::from(width), u64::from(height));
let budget = RenderLimits::default();
if budget.fit(size.0, size.1, None) != Some(size) {
return Err("render image exceeds preview limits".into());
}
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = Some(budget.max_pixels * 16);
reader.limits(limits);
let image = reader.decode().map_err(|e| e.to_string())?.to_rgba8();
if image.dimensions() != (width, height) {
return Err("render image dimensions do not match its job".into());
}
Ok(egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], image.as_raw()))
}
/// Keep the selected image and overwrite consent fixed while the upstream picker holds model input.
@@ -399,17 +658,21 @@ pub fn gpu_scene(scene: &ProductScene) -> Result<(u64, GpuScene), String> {
body.name.hash(&mut hash);
body.color.hash(&mut hash);
body.opacity.to_bits().hash(&mut hash);
body.transmission.is_some().hash(&mut hash);
hash_surface(body.surface, &mut hash);
let looks: std::collections::BTreeMap<_, _> = body.faces.iter().map(|l| (l.face, l)).collect();
for face in &body.faces {
face.face.hash(&mut hash);
face.color.hash(&mut hash);
face.opacity.to_bits().hash(&mut hash);
face.transmission.is_some().hash(&mut hash);
hash_surface(face.surface, &mut hash);
}
for (index, triangle) in body.mesh.triangles.iter().enumerate() {
let face = body.mesh.tri_face.get(index).and_then(|f| looks.get(f));
let (color, opacity, surface) = face.map_or((body.color, body.opacity, body.surface), |l| (l.color, l.opacity, l.surface));
let (color, opacity, surface) = face.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |l| {
(l.color, l.opacity, l.surface.filter(|_| l.transmission.is_none()))
});
if surface.is_some() && opacity != 1.0 {
return Err("opaque PBR requires opacity 1".into());
}
+461 -1
View File
@@ -2,6 +2,249 @@ use super::*;
use crate::scenario::Harness;
use solidcraft_engine::Session;
#[cfg(not(target_arch = "wasm32"))]
mod async_path_host {
use super::*;
use solidcraft_engine::render::job;
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// Advertise the path capability without opening a device or rendering anything.
struct TestExecutor;
impl job::ImageExecutor for TestExecutor {
fn descriptor(&self) -> job::Result<ExecutorDescriptor> {
let mut descriptor = job::CpuHost::cooperative().descriptor()?;
descriptor.id = ExecutorId::new("test-path").unwrap();
descriptor.profiles[0].features.push(solidcraft_engine::render::RenderFeature::Progressive);
Ok(descriptor)
}
fn admit(&self, _: &ProductScene, _: &job::ImageRequest, _: job::JobOptions) -> job::Result<job::Admission> {
panic!("setup tests must not render")
}
fn begin(&self, _: ProductScene, _: job::ImageRequest, _: job::JobOptions, _: job::CancelToken) -> job::Result<Box<dyn job::ImageWork>> {
panic!("setup tests must not render")
}
}
fn panel_fixture() -> Harness {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1600}));
enter(&mut h.app, true).unwrap();
h.frames(3);
h
}
fn finish_setup(h: &mut Harness) {
let deadline = Instant::now() + Duration::from_secs(10);
while matches!(h.app.render_workspace.path_host, PathHost::Preparing(..)) {
assert!(Instant::now() < deadline, "test setup worker did not finish");
h.frames(1);
std::thread::sleep(Duration::from_millis(1));
}
h.frames(3);
}
#[test]
fn design_frames_and_document_reset_never_start_the_deferred_path_host() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
let observed = calls.clone();
let mut h = Harness::new();
h.app.render_workspace.defer_path_host("selected viewport".into(), move || {
observed.fetch_add(1, Ordering::SeqCst);
job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string())
});
h.frames(5);
crate::documents::adopt(&mut h.app, Session::default());
h.frames(5);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(matches!(h.app.render_workspace.path_host, PathHost::NotStarted));
assert!(h.app.render_workspace.pending_setup.is_some());
enter(&mut h.app, true).unwrap();
finish_setup(&mut h);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_)));
enter(&mut h.app, false).unwrap();
enter(&mut h.app, true).unwrap();
h.frames(5);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn first_render_before_adapter_install_starts_once_when_the_adapter_arrives() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
let observed = calls.clone();
let mut h = panel_fixture();
assert!(matches!(h.app.render_workspace.path_host, PathHost::NotStarted));
h.app.render_workspace.defer_path_host("late selected viewport".into(), move || {
observed.fetch_add(1, Ordering::SeqCst);
Err("deliberate device refusal".into())
});
sync_backend(&mut h.app);
finish_setup(&mut h);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(matches!(h.app.render_workspace.path_host, PathHost::Failed(_)));
h.app.render_workspace.defer_path_host("must not retry".into(), || panic!("failed setup retried"));
enter(&mut h.app, false).unwrap();
enter(&mut h.app, true).unwrap();
h.frames(5);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn a_fallback_viewport_replaces_an_unstarted_factory_without_opening_the_old_device() {
let mut h = Harness::new();
h.app.render_workspace.defer_path_host("old hardware adapter".into(), || panic!("superseded device opened"));
h.app.render_workspace.defer_path_host("selected safe-mode adapter".into(), || Err("selected adapter unavailable".into()));
h.frames(5);
assert_eq!(h.app.render_workspace.setup_key.as_deref(), Some("selected safe-mode adapter"));
enter(&mut h.app, true).unwrap();
finish_setup(&mut h);
let PathHost::Failed(message) = &h.app.render_workspace.path_host else { panic!("expected selected adapter result") };
assert_eq!(message, "selected adapter unavailable");
}
#[test]
fn preparing_keeps_frames_responsive_then_registers_ready_host() {
let mut h = panel_fixture();
let cpu_host = h.app.session.render_job_host().unwrap().clone();
let (release, wait) = mpsc::channel();
let (started, thread) = mpsc::channel();
h.app.render_workspace.path_host.start(move || {
started.send(std::thread::current().id()).unwrap();
wait.recv_timeout(Duration::from_secs(10)).unwrap();
job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string())
});
assert_ne!(thread.recv_timeout(Duration::from_secs(10)).unwrap(), std::thread::current().id());
// A second viewport installation cannot replace or duplicate setup.
h.app.render_workspace.path_host.start(|| panic!("setup started twice"));
h.frames(3);
assert!(h.widget("GPU path tracer: compiling shaders…").is_some());
assert!(h.widget("Progressive rendering").is_none());
assert!(!h.app.session.render_backend().executors.iter().any(|e| e.id.as_str() == "test-path"));
let transparent = h.app.tr("render.image_transparent").to_string();
h.step(&json!({"widget":transparent})).unwrap();
assert!(h.app.render_workspace.view.transparent);
// Replacing the current document must retain the receiver while setup waits.
crate::documents::adopt(&mut h.app, Session::default());
assert!(matches!(h.app.render_workspace.path_host, PathHost::Preparing(..)));
enter(&mut h.app, true).unwrap();
h.frames(3);
assert!(h.widget("GPU path tracer: compiling shaders…").is_some());
enter(&mut h.app, false).unwrap();
release.send(()).unwrap();
finish_setup(&mut h);
assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_)));
enter(&mut h.app, true).unwrap();
h.frames(3);
assert!(h.widget("GPU path tracer: compiling shaders…").is_none());
assert!(h.widget("Progressive rendering").is_some());
assert_eq!(h.app.session.render_backend().executors.iter().filter(|e| e.id.as_str() == "test-path").count(), 1);
assert!(h.app.session.render_job_host().unwrap().same_host(&cpu_host));
h.step(&json!({"widget":"Progressive rendering"})).unwrap();
assert!(h.app.render_workspace.view.progressive);
crate::documents::adopt(&mut h.app, Session::default());
assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_)));
assert_eq!(h.app.session.render_backend().executors.iter().filter(|e| e.id.as_str() == "test-path").count(), 1);
}
/// The first setup on an adapter shows a spinner, the time so far and a note; a later one
/// a bar estimated from the time the first took, which the preferences keep.
#[test]
fn setup_progress_is_estimated_from_the_last_setup_on_the_adapter() {
let mut h = panel_fixture();
h.app.render_workspace.setup_key = Some("Test GPU | Vulkan | 1.0".into());
let (release, wait) = mpsc::channel::<()>();
h.app.render_workspace.path_host.start(move || {
wait.recv_timeout(Duration::from_secs(10)).unwrap();
job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string())
});
h.frames(3);
let first = h.app.tr("render.gpu_path_first_setup").to_string();
assert!(h.widget("GPU path tracer: compiling shaders…").is_some());
assert!(h.widget(&first).is_some(), "the first setup explains the wait");
assert!(h.widget("0:00").is_some(), "and counts the time");
release.send(()).unwrap();
finish_setup(&mut h);
let took = h.app.render_workspace.setup_seconds.get("Test GPU | Vulkan | 1.0").copied();
assert!(took.is_some_and(|s| (0.0..10.0).contains(&s)), "{took:?}");
// The next start on the same adapter has an estimate, kept in the preferences.
let mut h = panel_fixture();
h.app.load_prefs(&json!({"gpu_path_setup_seconds": {"Test GPU | Vulkan | 1.0": 120.0}}).to_string());
h.app.render_workspace.setup_key = Some("Test GPU | Vulkan | 1.0".into());
let (release, wait) = mpsc::channel::<()>();
h.app.render_workspace.path_host.start(move || {
wait.recv_timeout(Duration::from_secs(10)).unwrap();
job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string())
});
h.frames(3);
assert!(h.widget(&first).is_none());
assert!(h.widget("0:00 / ~2:00").is_some(), "a bar with the estimate");
release.send(()).unwrap();
finish_setup(&mut h);
let prefs: serde_json::Value = serde_json::from_str(&h.app.prefs()).unwrap();
assert!(prefs["gpu_path_setup_seconds"]["Test GPU | Vulkan | 1.0"].as_f64().is_some_and(|s| s < 10.0));
}
#[test]
fn hostile_setup_times_are_ignored() {
let times = load_setup_seconds(&json!({"a": -1.0, "b": 1e300, "c": "slow", "d": 30.0}));
assert_eq!(times.into_iter().collect::<Vec<_>>(), [("d".to_string(), 30.0)]);
let many: serde_json::Map<String, serde_json::Value> = (0..100).map(|i| (format!("gpu {i}"), json!(1.0))).collect();
assert_eq!(load_setup_seconds(&serde_json::Value::Object(many)).len(), MAX_SETUP_TIMES);
}
#[test]
fn setup_error_is_visible_and_survives_document_reset() {
let mut h = panel_fixture();
h.app.render_workspace.path_host.start(|| Err("test driver refused the device".into()));
finish_setup(&mut h);
let message = "GPU path tracer unavailable: test driver refused the device";
assert!(h.widget(message).is_some());
assert!(h.widget("GPU path tracer: compiling shaders…").is_none());
assert!(h.widget("Progressive rendering").is_none());
assert!(h.app.session.render_job_host().is_some());
completed_preview(&mut h, [200, 210, 220]);
crate::documents::adopt(&mut h.app, Session::default());
enter(&mut h.app, true).unwrap();
h.frames(3);
assert!(h.widget(message).is_some());
h.app.render_workspace.path_host.start(|| panic!("failed setup retried on viewport install"));
assert!(matches!(h.app.render_workspace.path_host, PathHost::Failed(_)));
}
#[test]
fn disconnected_setup_is_reported_in_the_panel() {
let mut h = panel_fixture();
let (tx, rx) = mpsc::channel();
h.app.render_workspace.path_host = PathHost::Preparing(rx, Instant::now());
drop(tx);
h.frames(3);
assert!(h.widget("GPU path tracer unavailable: setup worker stopped without returning a host").is_some());
}
#[test]
fn panicking_setup_is_reported_in_the_panel() {
let mut h = panel_fixture();
h.app.render_workspace.path_host.start(|| panic!("simulated driver panic"));
finish_setup(&mut h);
assert!(h.widget("GPU path tracer unavailable: GPU path tracer setup panicked").is_some());
}
#[test]
fn registration_failure_is_reported_in_the_panel() {
let mut h = panel_fixture();
// A different host with the same CPU descriptor cannot be registered twice.
h.app.render_workspace.path_host.start(|| Ok(job::CpuHost::cooperative()));
finish_setup(&mut h);
let PathHost::Failed(message) = &h.app.render_workspace.path_host else { panic!("duplicate executor must fail") };
assert!(h.widget(&format!("GPU path tracer unavailable: {message}")).is_some());
assert!(h.widget("Progressive rendering").is_none());
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() {
@@ -32,7 +275,15 @@ fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() {
let eye = camera.eye().to_f32();
let mut task = ImageTask::new(
scene,
ImageRequest { camera, width: SIZE, height: SIZE, background: [255; 3], transparent_background: false },
ImageRequest {
textures: None,
environment: None,
camera,
width: SIZE,
height: SIZE,
background: [255; 3],
transparent_background: false,
},
CancelToken::default(),
)
.unwrap();
@@ -211,6 +462,55 @@ fn saved_camera_survives_frames_and_product_fit_ignores_selection() {
assert_eq!(h.app.cam, design);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn progressive_image_controls_route_to_the_registered_host() {
let host = solidcraft_gpu::select::Policy::from_env()
.and_then(|policy| solidcraft_gpu::select::pick(policy).ok_or_else(|| "no qualifying adapter".to_string()))
.and_then(|(adapter, _)| solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string()));
let host = match host {
Ok(host) => host,
Err(e) => {
assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required GPU unavailable: {e}");
eprintln!("skipping progressive UI device test: {e}");
return;
}
};
let mut h = Harness::new();
h.app.render_workspace.path_host = PathHost::Ready(host);
sync_backend(&mut h.app);
h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap();
h.app.session.mark_saved();
h.call("ui.resize", json!({"width":1600,"height":1600}));
enter(&mut h.app, true).unwrap();
h.app.render_workspace.view.draft.width = 16;
h.app.render_workspace.view.draft.height = 16;
h.app.render_workspace.view.samples = 4;
h.frames(3);
let doc = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
h.step(&json!({"widget":"Progressive rendering"})).unwrap();
assert!(h.app.render_workspace.view.progressive);
let start = h.app.tr("render.image_start").to_string();
h.step(&json!({"widget":start})).unwrap();
let job = h.app.render_workspace.view.job.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while h.app.render_workspace.view.completed.is_none() {
assert!(std::time::Instant::now() < deadline, "progressive UI job did not complete");
std::thread::sleep(std::time::Duration::from_millis(5));
h.frames(1);
}
let status = h.app.session.execute("render.status", &json!({"job":job})).unwrap();
assert_eq!(status["frame"]["executor"], "gpu-path", "{status}");
assert_eq!(status["options"]["samples"], 4);
let export = h.app.tr("render.image_export").to_string();
h.step(&json!({"widget":export})).unwrap();
assert!(h.dir.join("render.png").is_file());
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
assert!(!h.app.session.is_dirty());
}
#[test]
fn clicked_render_workspace_saves_one_setup_and_restores_design_view() {
for language in ["en", "de", "ko", "ru"] {
@@ -291,6 +591,28 @@ fn product_gpu_buffers_preserve_opacity_and_never_contain_overlays() {
assert!(empty.tris.is_empty() && empty.glass.is_empty());
}
#[test]
fn transmission_uses_legacy_preview_buffers_and_invalidates_the_opaque_cache() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Glass"})).unwrap();
let appearance = json!({"bodies":["Glass"],"color":"#336699",
"pbr":{"metallic":0,"roughness":0.1,"dielectric_ior":1.5}});
s.execute("appearance.assign", &appearance).unwrap();
let (opaque_key, opaque) = gpu_scene(&solidcraft_engine::product_scene::extract(&s).unwrap()).unwrap();
assert!(!opaque.pbr.is_empty());
let mut scene = solidcraft_engine::product_scene::extract(&s).unwrap();
scene.meshes[0].transmission =
Some(solidcraft_engine::render::transmission::Transmission::new(solidcraft_engine::geom::Vec3::ZERO, None).unwrap());
let (glass_key, glass) = gpu_scene(&scene).unwrap();
assert_ne!(opaque_key, glass_key);
assert!(glass.pbr.is_empty());
assert!(!glass.tris.is_empty());
scene.meshes[0].opacity = 0.4;
let (_, transparent) = gpu_scene(&scene).unwrap();
assert!(transparent.pbr.is_empty());
assert!(!transparent.glass.is_empty());
}
#[test]
fn host_profiles_advertise_only_installed_opaque_pbr_not_hdri_refraction_or_final() {
use solidcraft_engine::render::*;
@@ -341,6 +663,100 @@ fn completed_preview(h: &mut Harness, background: [u8; 3]) -> solidcraft_engine:
panic!("small cooperative image did not complete within the work budget");
}
fn image_panel_frame(app: &mut SolveApp, ctx: &egui::Context) -> egui::FullOutput {
ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(285.0, 1200.0))), ..Default::default() },
|ui| image_controls(app, ui),
)
}
#[test]
fn completed_image_is_painted_once_available_and_upload_is_cached() {
let mut h = Harness::new();
let ctx = egui::Context::default();
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
assert!(h.app.render_workspace.image.is_none());
let doc = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
let job = completed_preview(&mut h, [20, 40, 60]);
h.app.render_workspace.view.job = Some(job);
let mut output = image_panel_frame(&mut h.app, &ctx);
let (cached_job, texture) = h.app.render_workspace.image.as_ref().unwrap();
let texture = texture.as_ref().unwrap();
let id = texture.id();
assert_eq!(*cached_job, job);
assert_eq!(texture.size(), [16, 16]);
let uploaded = &output.textures_delta.set.get(&id).unwrap().first().unwrap().image;
let egui::ImageData::Color(pixels) = uploaded;
assert!(pixels.pixels.iter().all(|p| *p == Color32::from_rgb(20, 40, 60)));
// egui requires frame texture deltas to be applied or cleared.
output.textures_delta.clear();
assert!(
ctx.tessellate(output.shapes, output.pixels_per_point)
.iter()
.any(|shape| { matches!(&shape.primitive, egui::epaint::Primitive::Mesh(mesh) if mesh.texture_id == id && !mesh.indices.is_empty()) })
);
let mut output = image_panel_frame(&mut h.app, &ctx);
assert!(!output.textures_delta.set.contains_key(&id), "repaint must reuse the texture");
// egui requires frame texture deltas to be applied or cleared.
output.textures_delta.clear();
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
let path = h.dir.join("displayed.png");
h.app.run("render.export", json!({"job":job,"path":path})).unwrap();
let frame = h.app.session.render_job_handle(job).unwrap().frame().unwrap().unwrap();
assert_eq!(std::fs::read(path).unwrap(), frame.png(), "displaying the image must not change export bytes");
}
#[test]
fn completed_image_keeps_last_export_during_work_and_updates_for_the_next_job() {
let mut h = Harness::new();
let ctx = egui::Context::default();
let first = completed_preview(&mut h, [20, 40, 60]);
h.app.render_workspace.view.job = Some(first);
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
let old_texture = h.app.render_workspace.image.as_ref().unwrap().1.as_ref().unwrap().id();
let reply = h.app.run("render.start", json!({"settings":{"width":16,"height":16}})).unwrap();
let pending = serde_json::from_value(reply["job"].clone()).unwrap();
h.app.render_workspace.view.job = Some(pending);
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
assert_eq!(h.app.render_workspace.image.as_ref().unwrap().0, first);
h.app.run("render.cancel", json!({"job":pending})).unwrap();
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
assert_eq!(h.app.render_workspace.image.as_ref().unwrap().0, first);
let second = completed_preview(&mut h, [80, 100, 120]);
h.app.render_workspace.view.job = Some(second);
let mut output = image_panel_frame(&mut h.app, &ctx);
let (cached_job, texture) = h.app.render_workspace.image.as_ref().unwrap();
assert_eq!(*cached_job, second);
assert_ne!(texture.as_ref().unwrap().id(), old_texture);
assert!(output.textures_delta.free.contains(&old_texture));
// egui requires frame texture deltas to be applied or cleared.
output.textures_delta.clear();
crate::documents::open_tab(&mut h.app, Session::default()).unwrap();
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
assert!(h.app.render_workspace.image.is_none(), "another document must not show this image");
crate::documents::switch(&mut h.app, 0);
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
assert_eq!(h.app.render_workspace.image.as_ref().unwrap().0, second);
reset_document(&mut h.app);
assert!(h.app.render_workspace.image.is_none());
}
#[test]
fn completed_image_decoder_preserves_alpha_and_rejects_invalid_sizes_and_pngs() {
let mut png = std::io::Cursor::new(Vec::new());
let pixels = image::RgbaImage::from_pixel(2, 1, image::Rgba([80, 120, 160, 128]));
pixels.write_to(&mut png, image::ImageFormat::Png).unwrap();
let decoded = decode_image(png.get_ref(), 2, 1).unwrap();
assert_eq!(decoded.size, [2, 1]);
assert_eq!(decoded.pixels, vec![Color32::from_rgba_unmultiplied(80, 120, 160, 128); 2]);
for (width, height) in [(0, 1), (u32::MAX, 1), (4096, 4096), (1, 1), (3, 1)] {
assert!(decode_image(png.get_ref(), width, height).is_err(), "{width}x{height}");
}
assert!(decode_image(b"invalid PNG", 2, 1).is_err());
}
fn async_export_fixture() -> (Harness, solidcraft_engine::render::job::JobId) {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap();
@@ -672,3 +1088,47 @@ fn four_and_five_k_viewports_preview_downscaled_within_the_limits() {
}
assert_eq!(preview_size(None, (5120, 2880)), (5120, 2880), "no executor: resolution reports it");
}
/// Backend failures must remain readable in the fixed-width Render panel, not
/// disappear into an ellipsis before the reason and recovery guidance are shown.
#[test]
fn backend_diagnostics_and_preview_help_wrap_without_elision() {
fn collect<'a>(shape: &'a egui::Shape, texts: &mut Vec<&'a egui::epaint::TextShape>) {
match shape {
egui::Shape::Text(text) => texts.push(text),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, texts)),
_ => {}
}
}
for mode in ["floating", "compact"] {
for dark in [false, true] {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.dark = dark;
h.call("ui.resize", json!({"width":1400,"height":1800}));
h.app.run("view.layout", json!({"mode":mode})).unwrap();
enter(&mut h.app, true).unwrap();
let reason = "The selected viewport adapter has no compatible Vulkan path device. Images remain available on the CPU; keep the original design and review the driver before retrying.";
h.app.render_workspace.path_host = PathHost::Failed(reason.into());
h.frames(3);
let expected = format!("{}: {reason}", h.app.tr("render.gpu_path_failed"));
let help = h.app.tr("render.opaque_preview_help").to_owned();
let mut out = h.ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1800.0))), ..Default::default() },
|ui| {
h.app.logic(ui.ctx());
h.app.ui(ui);
},
);
out.textures_delta.clear();
let mut texts = Vec::new();
out.shapes.iter().for_each(|shape| collect(&shape.shape, &mut texts));
for message in [&expected, &help] {
let text = texts.iter().find(|text| text.galley.job.text == *message).expect("the complete diagnostic is painted");
assert!(!text.galley.elided, "{mode}, dark={dark}: {message}");
assert!(text.galley.rows.len() > 1, "long messages must wrap in the narrow panel");
assert!(text.galley.rect.width() <= 285.0, "the message must not expand the panel");
}
}
}
}
@@ -7,6 +7,8 @@
{"expect": {"dialog": "Appearance", "shown": "Remove override"}},
{"note": "The shared 28 px controls make this card scroll; reveal Search inside its body."},
{"call": "ui.scroll", "params": {"x": 1345, "y": 650, "delta": -400}},
{"note": "Let the scroll animation settle before resolving the moving Search handle; the larger starter catalog otherwise puts a swatch under that click."},
{"frames": 20},
{"click": {"handle": "appearance:search"}},
{"text": "no-such-appearance"},
{"expect": {"shown": "No matching appearances"}},
+38 -12
View File
@@ -1,9 +1,10 @@
# Local scalar libraries
# Local material libraries
The first local-library slice (#846) reuses the existing appearance and physical
material commands. It is a small consumer of the broader [library proposal](libraries.md),
not the complete resource-pack implementation. It adds no texture mapping, wood grain,
PBR shader, HDRI lighting, online discovery or document-format version.
The first scalar slice (#846) and portable v2 extension (#1062) reuse existing
appearance/material commands. V2 transports existing opaque PBR looks and validated
engineering cards; it does not add a shader, texture mapping, wood grain, HDRI,
online discovery or document-format version. This remains a small consumer of
the broader [library proposal](libraries.md).
## Desktop workflow
@@ -16,7 +17,7 @@ PBR shader, HDRI lighting, online discovery or document-format version.
3. Open that library in another design, search its appearance presets, select one and
choose **Apply library appearance**. The normal body/face/component targets are reused.
OK after this immediate application does not add a second undo step.
4. Save the design. Its selected scalar definitions are ordinary copied design data;
4. Save the design. Its selected definitions are ordinary copied design data;
the source library can be moved or removed without changing the saved result.
Export does not edit the design, its dirty state or history. An existing file is refused
@@ -72,11 +73,30 @@ outer undo transaction through the existing material commands. Mass remains the
volume × density calculation; a generic illustrative density is not an engineering grade.
`library.export` also accepts `id` (default `user.local`), `name` (default `My library`),
`provenance` and `overwrite` (default false). Only explicit assignments/design-local
`provenance`, `overwrite` (default false) and `format` (default `solidcraft-library/2`).
Explicit `solidcraft-library/1` refuses PBR or engineering-card loss before publishing,
including with overwrite enabled. Only explicit assignments/design-local
definitions are inspected, without evaluating geometry. Duplicate exact definitions are
deduplicated; different values with the same display name remain distinct entries.
## Scalar catalog v1
## Portable catalog v2 and scalar v1 compatibility
`solidcraft-library/2` extends the same envelope and definition kinds with optional
`pbr: {metallic, roughness, dielectric_ior}` on appearance values and optional
`physical_card` on material values. PBR uses the existing bounded opaque contract
and requires opacity exactly one. Cards use the existing bounded SI validator;
source/condition records survive import, but validation does not authenticate or
approve their sources. A card does not replace assigned mass density or supply a
solver. Materials can carry both a card and an independent default PBR look.
Absent richer fields are omitted from serialization. Existing v1 scalar bytes,
content revisions and identities are preserved exactly; regression fixtures pin
an appearance and a material revision from the pre-v2 writer. Changing only the
envelope version does not change a revision. Nonempty PBR/card data participates
in it. V1 wire input rejects even null v2-only fields; it cannot disguise richer
data with an older format marker. Older readers refuse v2.
The following v1 shape remains readable and writable explicitly:
The independent JSON format marker is `solidcraft-library/1`. It has `id`, `name` and an
`entries` array. Each entry has `id`, `revision`, `provenance` and a typed `definition`:
@@ -105,18 +125,24 @@ Provenance contains a required `license`, optional `author` and optional `source
It is supplied metadata, not verified permission or a publisher signature. URLs are never
fetched. The default `LicenseRef-User-Provided` does not claim that user data is openly
licensed. No external art/data is bundled in this slice. Catalog provenance/revisions stay
in the catalog: the first consumer copies scalar values into existing `Look`/`UserMaterial`
records, without a persistent catalog-origin/update receipt. Full resource embedding,
in the catalog: values are copied into existing `Look`/`UserMaterial` records,
without a persistent catalog-origin/update receipt. Engineering-card sources and
conditions belong to the card and are copied with it. Full resource embedding,
design-owned provenance, scopes and explicit update links remain proposed follow-ups.
## Bounds and failure behavior
The reader accepts at most 4 MiB encoded JSON and 1,024 entries. Entry decoding stops at
The reader accepts at most 4 MiB encoded JSON and 1,024 entries. A bounded raw wire
preflight preserves v1 field rules before typed decoding. Entry decoding stops at
the count cap with fallible vector growth. Names are trimmed, nonempty and at most 128
UTF-8 bytes; provenance license/author/source URL limits are 128/256/2,048 bytes. Control
characters, invalid identifiers, duplicate identities, bad revisions and nonfinite scalar
values fail. Export inspects at most 100,000 source references, deduplicating into at most
1,024 entries. These are encoded/work limits, not a process-memory or latency guarantee.
1,024 entries. Rich export bounds retained unique definitions before whole-catalog
encoding, reserving 4 KiB for the envelope and counting each entry's pretty bytes plus
nested-line indentation. This conservative writer admission can refuse a catalog
close to 4 MiB even when a compact representation fits. These are encoded/work limits,
not a process-memory or latency guarantee.
Native reads are bounded regular-file reads; browser reads check VFS size before cloning.
Native no-clobber writes reuse atomic new-file publication; explicit replacement is atomic
+31 -2
View File
@@ -3,8 +3,9 @@
`material.properties.validate {card}` checks a caller-supplied candidate record,
without assigning it, saving it in the design, reading files, fetching sources or
changing history, selection, camera or live geometry. This is phase 1 of #923/#922,
building towards #785. Existing `material.create/edit` and their density/appearance
behavior are unchanged. The design format is unchanged.
building towards #785. The initial validator remains read-only. The design-owned persistence
section below extends `material.create/edit` without changing assigned density or
optical appearance.
Validation means numerical and structural consistency. It does not authenticate
sources, approve redistribution, certify design values, provide a solver or enable
@@ -189,3 +190,31 @@ The card's test conditions still apply; changing direction does not extrapolate
temperature, moisture, process, rate, damage or nonlinear behavior. Synthetic analytic
tests cover principal axes, the isotropic limit and off-axis shear/coupling terms.
No physical dataset or external implementation is included.
## Design-owned persistence
`material.create` accepts an optional `physical_card`; omission or null means
unknown. `material.edit` retains an omitted card, validates and replaces a supplied
card, and clears it only with explicit null. Density-only, appearance-only and
rename edits retain the card. Assignment, configurations, undo/redo and Paste New
keep using the same design-owned material definition.
`material.list` reports the optional card, its validation report and a density
comparison. Assigned density in g/cm3 and recorded density in kg/m3 stay separate:
card data never silently changes the density used for mass. Source and condition
claims are preserved, not certified, and this does not add an analysis solver.
The single pending descriptor is `material-engineering-cards.rs`. The build
assigns the pending format automatically; no released number, history or migration
is edited. Older designs keep absent cards as unknown. Older readers refuse the
newer saved format rather than silently losing engineering records.
Only `user_materials[].physical_card` receives the typed SI admission needed for
elastic moduli above coordinate limits. Other numeric/depth limits, including the
second range check after integer-valued coordinates become typed floats, remain
unchanged. Normal loading admits at most 1000 design-owned materials.
Scalar catalog v1 refuses cards before creating or replacing an export file.
Rich catalog transport is the separate #1086 contribution. The synthetic example
`examples/material-card.json` uses an assigned density of 0.8 g/cm3, a recorded
700 kg/m3 and invented isotropic constants; it is not a real material dataset.
+46
View File
@@ -0,0 +1,46 @@
# Anisotropic GGX numerical core
`render::anisotropic_surface` supplies the independent optical core tracked in
#1694/#1699. This constituent extracts the original source and analytic tests
already published in #1698 at `463a3e0daaa6b84f169ba5f8f215fba4642c194a`.
It adds no renderer registration, saved field, command, device dependency or
engineering-material assignment. Runtime consumers remain separate work.
## Contract
`VisualFrame::new` admits finite, nonzero, perpendicular normal/tangent inputs,
normalizes them, and constructs a right-handed visual frame. This is not a physical
material-axis assignment. An original geometric normal is supplied separately;
response and reflected samples reject either hemisphere when appropriate.
Both perceptual roughness axes use the opaque surface range `[0.045, 1]`; alpha is
roughness squared. Linear-sRGB reflectance, metal fraction and dielectric IOR use
the existing `OpaqueSurface` admission. Invalid data is refused, not clamped.
Equal roughness axes preserve the isotropic opaque BRDF contract exactly.
Sampling uses the projected **normal distribution (NDF)**, not a visible-normal
sampler. `normal_pdf` is `D(h) * n.h`; the unconditional reflected-direction PDF
includes the `1 / (4 * v.h)` Jacobian. A rejected microfacet is a null event that
must remain in the sample denominator. Never retry until a sample succeeds.
Reflection weights contain the specular component only. Diffuse/environment
mixtures, visibility, ray spawning, image budgets and multiple scattering belong
to the consumer; this module does not claim to implement them.
The implementation is original Rust using elementary GGX/Smith/Schlick equations.
The public mathematical reference is Heitz, JCGT 7(4), 2018, equations 1 and 2:
https://jcgt.org/published/0007/04/01/. No external implementation was adapted.
## Reproduce the focused checks
```sh
cargo test --profile ci -p solidcraft-render --lib anisotropic_surface
cargo clippy --profile ci -p solidcraft-render --all-targets -- -D warnings
cargo check --target wasm32-unknown-unknown -p solidcraft-render
```
Analytic and deterministic quadrature tests cover hostile inputs, orthonormality,
normal-incidence peaks, isotropic equivalence, reciprocity, 90-degree visual-axis
rotation, geometric/shading hemisphere separation, grazing directions, inverse-CDF
sampling, PDF/throughput agreement and retained null-event mass. Broad-lobe energy
checks are scoped consistency tests, not universal energy certification, measured
material data or evidence of an integrated rendered image.
+2
View File
@@ -1,5 +1,7 @@
# Render backend capabilities (R1)
Native progressive opaque PNG rendering is described in [Progressive product images](render-path.md). It registers alongside the CPU executor.
This page describes the R1 metadata contract. The installed GPU viewport and CPU
product-job hosts extend it through complete profiles; HDR input preparation adds
no lighting capability. See [the integrated phase boundaries](render-integration.md).
+53
View File
@@ -0,0 +1,53 @@
# Runtime clear coat
Progressive image jobs accept an independent dielectric GGX coat over an opaque
metallic/roughness substrate. A dark, moderately rough plastic base with a smooth
coat can show narrow softbox reflections while retaining a dark body. Chrome uses
the existing metallic surface; it does not need a coat.
```json
{"command":"render.start","params":{"progressive":true,"raytrace":{"clear_coats":[{"body_index":0,"body_name":"Housing","weight":1,"roughness":0.12,"ior":1.5}]}}}
```
`render.preflight` accepts the same override. Index and exact name must both match
the captured `render.scene` body. At most 64 distinct bodies can be overridden.
Weight is finite in [0,1], perceptual roughness in [0.045,1], and relative IOR in
[1,3]. Unknown fields and repeated indices are errors. Weight zero is a no-op.
Coats cannot be combined with runtime transmission on the same body. Overrides
apply to the body and its faces in the job snapshot, never to the saved document,
preview, undo history or appearance format. They require progressive rendering.
The implementation adds single-scattering GGX/Smith coat reflection with its own
Schlick Fresnel term. For cosine `c`, define the conservative directional bound
`B(c) = Fcoat(c / sqrt(2 * (1 + sqrt(1 - c*c))))`. The base BRDF is multiplied by
`(1-weight*B(N.V))*(1-weight*B(N.L))`. This is deliberately stronger attenuation
than macro-normal Fresnel, which can leak energy with rough coats over shiny bases.
For any reflected upper-hemisphere direction, the view/half-vector cosine is at
least the argument used in B. Schlick decreases with cosine, and a unit-Fresnel
single-scattering Smith GGX lobe reflects at most one. Thus the coat contributes
at most `weight*B(N.V)`; the attenuated unit-energy base contributes at most
`1-weight*B(N.V)`. The symmetric product retains reciprocity. This conservative
allocation can darken grazing views and omits internal inter-layer scattering and
refracted substrate directions; it is not a full layered transport solution.
Importance-sampled f64 furnace quadrature covers shiny bases, rough coats, partial
weights, IORs and grazing directions, alongside analytic and reciprocity checks.
Continuation selects the coat GGX lobe with probability `weight/2`, otherwise the
existing equal cosine/GGX base mixture. Evaluation uses the complete layered BRDF;
the PDF is the same combined mixture for continuation and direct-light MIS. GGX
samples below the surface are rejected without renormalizing the PDF. Zero weight
preserves the old BRDF and random-number consumption exactly. GPU BRDF/PDF tests
compare the production shader to the independent f64 numerical contract, including
roughness 0.045 and grazing directions.
The per-triangle resolved material occupies 64 bytes, including a 16-byte coat
record. Storage-limit checks and peak-memory estimates include this additional
record.
The model was designed from the public descriptions of
[layered materials in PBRT](https://pbr-book.org/4ed/Light_Transport_II_Volume_Rendering/Scattering_from_Layered_Materials)
and [Filament's clear-coat model](https://google.github.io/filament/main/filament.html#materialsystem/clearcoatmodel).
These motivate independent interfaces and substrate attenuation; this implementation
is original and deliberately simpler than a multilayer random walk. No reference
implementation source was copied.
+51
View File
@@ -0,0 +1,51 @@
# Optional spatial denoising
Native progressive GPU image requests accept `"denoise": true`. Omission and
explicit `false` preserve the existing raw image path and allocate no denoising
buffers or pipelines. This runtime flag is not stored in designs or appearances.
The request is rejected on non-progressive or non-GPU executors.
The optional final pass runs after all requested path samples, on linear
premultiplied radiance, before the existing single exposure/tone-map/sRGB output
step. It never changes the raw linear accumulator or the path random-number
stream. Raw radiance and medium diagnostics are checked first, so denoising
cannot conceal a failed glass path. To retain both raw and filtered PNGs, submit
two otherwise identical jobs with the flag false and true respectively.
The original implementation uses three edge-aware a-trous passes with pixel
steps 1, 2 and 4 and the separable five-tap kernel [1,4,6,4,1]. It is inspired by
the published method of [Dammertz, Sewtz, Hanika and Lensch, HPG 2010](https://holger.dammertz.org/publications/dammertz10hpg_bibtex.html).
No third-party filtering source or weights are copied. A deterministic pixel-centre
ray supplies textured albedo, shading normal, body identity, world position and
depth. Bilateral weights use albedo difference, normal agreement, distance from
the local tangent planes, axial depth difference, and radiance contrast. Spatial thresholds scale with
the projected pixel footprint. Every pass retains the original alpha; the
online convex average avoids overflowing radiance multiplied by kernel weights.
First-hit guides cannot describe an image seen through refraction or a sharp
reflection. Pixels first hitting glass, metals with roughness below 0.25,
near-mirror coats with roughness below 0.05, light emitters, background or
partial coverage bypass the filter. Glossier coats are filtered: on the studio
coffee scenes their broad reflections of the cyclorama and area lights keep
their shape, while the grain from choosing between coat and base layers is
removed. A coat reflecting fine detail can lose some of it. First-hit guides also do not model depth-of-field correspondences:
the centre lens ray is used, so out-of-focus boundaries may retain noise or
acquire spatial bias. Rough glossy effects and soft shadows can lose detail.
This is a biased, optional spatial filter, not a temporal or learned denoiser,
and it cannot guarantee preservation of every lighting or texture feature.
Preflight includes the enabled method, three passes, guide policy, bypass cases
and these limitations. The job options retain the explicit flag. A job admits
80 additional GPU bytes per pixel only when enabled: 48 for guides and 32 for
two colour buffers. The 512 MiB job estimate and each device storage-binding
limit apply. At 1920x1080 the guide buffer is 99,532,800 bytes; larger dimensions
may be rejected before allocation rather than silently reducing resolution.
The guide pass reuses the existing six-storage-buffer path layout; the filter
needs three storage buffers. Cancellation is checked between bounded tiles.
Regression coverage includes an independent f64 gather oracle, constant-image
and coverage identity, checker noise reduction, albedo/normal/depth/body edge
preservation, synthetic GPU/oracle agreement, production centre-ray guides,
default/explicit-false image identity, raw-buffer preservation, admission and
runtime preflight behaviour. GPU checks must be run on the target backends;
source-level and shader validation alone do not establish rendered quality.
+102
View File
@@ -0,0 +1,102 @@
# Runtime sun and environment lighting
These controls extend progressive image jobs. They do not change the saved design,
its render setups, the viewport lighting, or the legacy default render.
An original artistic sky is available without downloading an image:
```json
{
"progressive": true,
"samples": 256,
"max_depth": 8,
"environment": {
"kind": "sky",
"gradient": {"zenith": [0.16, 0.32, 0.7], "horizon": [0.8, 0.9, 1.0], "ground": [0.12, 0.1, 0.08]},
"intensity": 1,
"rotation_rad": 0
},
"lighting": {
"sun": {"direction": [-0.4, -0.6, 0.7], "angular_radius_rad": 0.02, "radiance": [1000, 950, 850]}
},
"tone_map": "aces_fitted"
}
```
The gradient is an original horizon-to-zenith/ground interpolation, sampled to
512x256. It is an artistic environment, not a calibrated atmospheric model.
Sun direction points toward the light. Its finite angular disk has uniform linear
RGB radiance and supports radii 0.001..pi/2 radians. Increasing radius at fixed
radiance increases total illumination; the cone's solid angle is
`4*pi*sin(radius/2)^2`. The sun is a separate additive emitter, so a sun already
present in an HDR image is not automatically removed.
For a captured Radiance HDR file, replace the environment object with:
```json
{"kind": "hdr", "path": "original-studio.hdr", "assume_linear_srgb": true, "rotation_rad": 0.3, "intensity": 1}
```
Input is bounded to 16 MiB encoded, 2048x1024, 2:1 equirectangular and the standard
`-Y +X` orientation. Header exposure and channel corrections are applied by the
existing decoder. Declared primaries must be sRGB/D65. A source without primaries
requires explicit `assume_linear_srgb: true`; this is an interpretation choice,
not colour conversion. Other declared primaries are rejected even when that flag
is set. No HDR values are tone mapped before lighting. Intensity is 0..100 and
rotation is a finite bounded angle in radians about Z.
Equirectangular +Z is the north pole, -Z the south pole. Column zero begins at +X;
columns run toward +Y. Positive rotation turns the image about +Z. Horizontal
coordinates wrap; polar rows do not repeat. Map radiance replaces the constant
`lighting.environment` when a map is supplied. Rectangle and sun emitters remain
additive. The camera backplate remains the request's background; environment
radiance appears through surface lighting, reflections and refractions.
The CLI accepts `--progressive --environment FILE.json`; that small JSON file
contains the environment object above. `--lighting FILE.json` supplies the sun
and/or rectangle lighting object. The engine captures bounded file bytes through
its scoped VFS. Queued jobs own immutable shared bytes and never reopen paths.
Preflight reports source SHA-256, dimensions and colour policy. A later start is a
new capture; compare its descriptor if a source file may have changed.
## Sampling and bounded preparation
The worker uses the existing cooperative HDR decoder, then builds an alias table
from luminance times exact texel solid angle. A row's solid angle per texel is
`(2*pi/width)*(cos(theta_top)-cos(theta_bottom))`, evaluated with a stable sine
identity near the poles. Within a selected texel, azimuth and cosine of polar
angle are sampled uniformly. The directional density is texel probability divided
by solid angle. Radiance uses the same piecewise-constant texel mapping. A black
image uses a uniform distribution with zero emitted radiance. Independent random
variates select the alias bucket and coin; sharing one 24-bit variate would bias
large maps.
The sun samples its cone uniformly. Environment and sun are sampled separately,
and each escaped BSDF ray receives each emitter's radiance with its own matching
power-heuristic weight. The existing opaque-branch, delta-event and final-bounce
rules apply. Thin-sheet branch densities multiply the competing BSDF density;
connections that exhaust the remaining depth use NEE only. Enabled straight
solid-shadow connections also use NEE only, with their transmitted emitter hits
suppressed to avoid double counting, as described in `render-glass-shadows.md`.
This extends the original estimator from
[PBRT fourth edition, sections 12.5](https://pbr-book.org/4ed/Light_Sources/Infinite_Area_Lights)
and [13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer);
no PBRT source or images are copied.
GPU storage uses one additional binding, 32 bytes per texel plus a 16-byte header.
Admission conservatively adds retained encoded-buffer capacity, 160 bytes per texel and 64 KiB to the
job's existing estimate for decoded/table/work/staging/device buffers. Device
binding limits and the existing 512 MiB job limit also apply. Preparation checks
cancellation while decoding, generating rows, building tables and staging uploads.
CPU product rendering rejects environment resources explicitly.
Tests use original in-memory RGBE fixtures and the generated gradient. They cover
colour policy, malformed inputs, cancellation, polar/seam lookup, PDF normalization,
unequal-mass sampling frequencies, resource admission, furnace consistency, sun
cone quadrature and immutable engine preflight. No third-party HDRI or material
assets are bundled. Original fixture generators live in `examples/studio/textures`;
planar runtime colour/normal mapping is described in `render-runtime-textures.md`.
The showcase PNG maps and RGBE gradient are generated locally before replay with
`python examples/studio/author_showcase.py`. Image outputs are ignored and are not
committed source assets; the recipes, generators and provenance remain editable.
+13
View File
@@ -0,0 +1,13 @@
# Bounded linear film accumulation
`solidcraft_render::film` is the independent accumulator for #1696. It is extracted from the original renderer donor at `3b892eab`; it does not install a CPU integrator, PNG resolver, camera coverage buffer or progressive UI.
Samples are nonnegative finite linear RGB. Per-pixel running means and unbiased sample variance remain linear: no exposure, denoising, tone mapping or display conversion occurs here. A later consumer applies its output transform exactly once after resolving a complete pass. Sample order is significant; splitting the same ordered samples into bounded contiguous calls preserves identical results, while arbitrary floating-point reordering is not promised.
The caller reserves `required_bytes` in the existing image-job lease and retains it throughout the film lifetime. Allocation is fallible and actual capacity is checked. Dimensions, pixel count, sample count, storage and update sizes are bounded. This module creates no second pool, thread, cache or cancellation token. Initialization and updates debit the same caller-provided cumulative work budget.
`add_range` stages at most 256 pixel updates before committing. Invalid input, range/count overflow, exhausted work or cancellation before the bounded commit leaves prior pixels unchanged; consumed work is not refunded. Cancellation after commit must still be checked by the consumer before publication.
`complete_pass` refuses empty or uneven sample counts and returns an immutable borrowed view, not an owned or publishable image. The caller must separately admit any output snapshot and enforce its existing generation/cancellation publication lock. Camera background coverage and actual progressive image delivery remain separate integration work.
No design data is newly saved, so no format descriptor is introduced. No extra dependency, asset, geometry operation or renderer capability is advertised.

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