Compare commits

...

55 Commits

Author SHA1 Message Date
selune 545191528d Preserve Result overlay WIP and original native acceptance documents (#6201) 2026-10-31 12:59:28 +01:00
selune b3a01bf400 Preserve CPU compatibility WIP and exact C28 host; validation remains incomplete 2026-10-31 12:25:43 +01:00
selune 7cf392d7c3 Preserve every-pass CPU source and reviewed SHA-state recovery; validation pending 2026-10-31 10:40:53 +01:00
selune dbc6041ba7 archive: preserve reviewed CPU high-pass film and Interactive source, unrun 2026-10-31 09:12:32 +01:00
selune 53ef5bb7c2 docs(render): preserve exact C24 quality-test macro recovery WIP 2026-10-31 05:38:12 +01:00
selune 53a60b772d chore: preserve reviewed U04-owner-integration source proposal and provenance 2026-10-31 04:41:55 +01:00
selune 57257eaecd chore: preserve reviewed CPU directional-shadow UI source checkpoint 2026-10-31 04:25:53 +01:00
selune 299374885f chore: preserve reviewed quality buttons and TIR test-call source checkpoint 2026-10-31 04:10:19 +01:00
selune eb848ff3c8 archive: coherent C22 render source increment and exact bindings (unrun) 2026-10-31 02:55:45 +01:00
selune c42b4c1e19 archive: reviewed CPU directional RGB consumer source (uncompiled and unrun) 2026-10-31 02:39:07 +01:00
selune 55dc8e2e2c archive: align dielectric lowering test contract and initial docs (unrun source) 2026-10-31 02:04:42 +01:00
selune 7a66117304 archive: preserve reviewed physical-glass library staging (unrun source) 2026-10-31 01:55:58 +01:00
selune fffecd56a9 archive: preserve reviewed CPU RGB lighting prerequisite (unrun source) 2026-10-31 01:26:14 +01:00
selune 7464a9ba23 docs: preserve original native progressive and glass acceptance procedures (not run) 2026-10-31 01:19:01 +01:00
selune f76b8b8720 archive: preserve reviewed CPU direct-visibility owner successor (unrun source) 2026-10-31 01:14:16 +01:00
selune 4f492f6ae3 archive: preserve reviewed Raster local-light runtime consumer (unrun source) 2026-10-31 00:55:46 +01:00
selune f346faa785 archive: preserve minimal carried Render image-test recovery (unrun source) 2026-10-31 00:48:30 +01:00
selune 169ed3b934 wip(render): preserve bounded CPU RGB direct-visibility foundation 2026-10-30 23:53:51 +01:00
selune 25c9527bbc docs(render): correct archived U17 source test-module label 2026-10-30 23:36:26 +01:00
selune 9e41909c05 wip(render): preserve adopted C20 corrections and independent Raster light helper 2026-10-30 23:23:28 +01:00
selune 6f7f101846 wip(render): preserve strict dielectric transport, saved policy and original acceptance source 2026-10-30 22:42:33 +01:00
selune f1697fa9db wip(render): preserve explicit-light color correction and connected UI documentation 2026-10-30 22:32:10 +01:00
selune a46f1b8373 wip(render): preserve coherent preview, saved-lighting and exposure source 2026-10-30 22:10:24 +01:00
selune 928d07706d wip(render): preserve reviewed HDR test, sampling presets and CPU exposure proposals 2026-10-30 21:05:54 +01:00
selune 9001a87ea4 docs(render): update issue progress with exact C17 partial test evidence 2026-10-30 20:27:32 +01:00
selune 5a555d4741 feat(render): preserve reviewed paused camera and Raster directional source units 2026-10-30 19:36:10 +01:00
selune f0477d474b docs(render): publish honest U01-U17 evidence milestone charts and remaining work 2026-10-30 19:15:09 +01:00
selune 7ef3f51de4 feat(render): preserve C16 fixture defaults and automatic Result preference with exact formatting bridge 2026-10-30 19:00:37 +01:00
selune 415fb00232 feat(render): preserve exact adopted 19-path source with reviewed progressive final feedback and optics 2026-10-30 18:09:40 +01:00
selune 0175e0c5e6 feat(render): preserve manual final feedback, progressive optics and precise cancellation repairs as reviewable WIP 2026-10-30 17:33:55 +01:00
selune c2e0a97c74 feat(render): preserve protected CPU feedback composition with source-reviewed error accounting 2026-10-30 16:06:23 +01:00
selune a82f4ffd82 feat(render): preserve coherent reviewed CPU partial-stream source with corrected visibility and accounting 2026-10-30 15:30:15 +01:00
selune c504532744 docs(render): select explicit bounded feedback work envelope preserving ordinary render allowance 2026-10-30 15:15:14 +01:00
selune 84704806bb fix(render): preserve reviewed partial-producer tick correction and outstanding work-budget limit 2026-10-30 14:57:43 +01:00
selune 02ad76be21 feat(render): preserve unrun partial-film host producer and narrow-card fix proposals 2026-10-30 14:46:28 +01:00
selune 791c1366e7 docs(render): attribute original long-scene fixture assets 2026-10-30 14:38:17 +01:00
selune 9dfebc997c test(render): preserve original detailed scene and synthetic HDR without claiming execution 2026-10-30 14:36:55 +01:00
selune 1e27ac72bb docs(render): preserve actual narrow-window Pause regression and stopped test result 2026-10-30 14:32:03 +01:00
selune ee779b6f79 docs(render): record completed build and selection stop without claiming test passes 2026-10-30 14:29:02 +01:00
selune b50e70451d docs(render): index current candidate and reviewed parity preparation truthfully 2026-10-30 14:10:10 +01:00
selune 5de18a496e docs(render): preserve reviewed parity preparation and original unrun acceptance cases 2026-10-30 13:30:34 +01:00
selune f6d3362ed2 docs(render): checkpoint complete current53-path C12 candidate without validation claims 2026-10-30 13:11:54 +01:00
selune 7a979a80b2 docs(render): preserve independently reviewed CPU nested-box V2 source 2026-10-30 13:03:44 +01:00
selune 129c2a1c81 docs(render): preserve real Interactive light tests and blocked glass engine work 2026-10-30 12:57:32 +01:00
selune 8790d601cc docs(render): preserve progressive Area increment and unreviewed CPU nested-box proposals 2026-10-30 12:31:41 +01:00
selune b73cf3edd1 docs(render): preserve reviewed help GPU diagnostics and Raster receiver fixes as unrun WIP 2026-10-30 12:20:40 +01:00
selune ac96dc66e9 docs(render): preserve unrun EV HDR and retained-final acceptance journeys 2026-10-30 12:00:50 +01:00
selune 738cfbaf9b docs(render): preserve immediate-release and selection regressions as unrun WIP 2026-10-30 11:56:01 +01:00
selune 174c59209c Archive unrun hidden-wait input guard prototype and original cases 2026-10-30 10:30:06 +01:00
selune 8f13d82145 Preserve combined Interactive follow-ups, partial native findings and admission stop 2026-10-30 10:05:46 +01:00
selune b94c65b005 Archive unrun native Interactive placement and waiting-canvas proposals 2026-10-30 09:36:20 +01:00
selune 83e18240cb docs(render): record scoped C7 validation and native acceptance boundary 2026-10-30 07:23:34 +01:00
selune 297bd144df docs(render): preserve engine test correction and native acceptance plan
Source archive requested by Judit; no production registration. C7 strict Clippy and wasm check passed; native build/tests and application acceptance remain pending.
2026-10-30 07:11:52 +01:00
selune 2cbad02957 docs(render): preserve test-helper correction and pending parity follow-ups
Source archive only, not a qualified production feature. C3 selected tests passed before a test-helper lint failure; corrected source and conditional CPU/Raster patches await validation.
2026-10-30 00:21:33 +01:00
selune d66268179d docs(render): preserve complete reviewed WIP source and pending parity proposals
Source-preservation checkpoint requested by Judit; not a qualified feature commit. Combined engine/UI compilation cancelled for resources; full gates and native acceptance remain pending. Production baseline unchanged.
2026-10-29 22:38:45 +01:00
522 changed files with 471152 additions and 0 deletions
@@ -0,0 +1,6 @@
| proposals/render/2026-10-30/original-long-scene/fixture-spec.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/geometry.commands.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/optional-glass-probe.commands.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/original-asymmetric-srgb.hdr | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/raster-comparison.request.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/static-checks.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
+226
View File
@@ -0,0 +1,226 @@
{
"utc": "2026-10-29T21:37:05.005566+00:00",
"parent": "a077906234972dbbf11e80c54893f85ece97c8b7",
"source_files": {
"assets/locales/pending/render-interactive.json": "4502966fe59189042d2420defa75091036599c0cb14d573027eb76fca1511a63",
"crates/engine/src/cmd/mod.rs": "ee2a6307c9bc27436ab1d381e1994f44feb1336055d8f9c0bda9dcaa23cb4ebb",
"crates/engine/src/cmd/render.rs": "6a1e3f963783adef24f2094e8c648e6d53ae1cc893c953a0ecc1bbb2067bfa24",
"crates/engine/src/cmd/render/jobs.rs": "1cb96c6189fbbf9b904579d6c230aff004004249fcd89a86c3a82d87dc41bd68",
"crates/engine/src/cmd/render_ui.rs": "45d0f233b6788de8f28037cc9ea4447aed19405334f6648dc60943c24672d020",
"crates/engine/src/lib.rs": "3e1109f30b89552f7114276b5ca0fd5d335f845132bca798a091ce7d1a917701",
"crates/engine/src/render_interactive.rs": "c6aecaed7a5f83dec1e158161854e9284213c938015df192566f4511b57ed7b0",
"crates/engine/src/render_jobs.rs": "ca096820db7ecc6b5cd234d25ab9bdbb9f62e06b2ce876a2c8707d0838d6e5af",
"crates/engine/src/schema/render_ui.rs": "80793c893f50e4dc2f785ff5dd7652c1ef984a4f307e9225a6196203812b4653",
"crates/render/src/job/host.rs": "7d2e248f8c7d6586f18d8b9840da9074514ed4672bc8a8845170a805b1bf9315",
"crates/render/src/job/host_tests.rs": "0febf5af52d7aa24f3b0586e230752a17b6c0e30b33d79810c8b7338a34c941a",
"crates/render/src/job/interactive.rs": "ccc8aaa5fc76038b39e8829f87b0888ccd92816567ea8b3d83778201364a4cce",
"crates/ui-egui/src/command_help.rs": "861f005ae58c4d685ef8e2d30e372454607755a3eea3854b37f33147758930ba",
"crates/ui-egui/src/render_ui/mod.rs": "d5599245102801e15d592ee28f069bdcb1f4afb1beaec3c5e85eeb5be8312df2",
"crates/ui-egui/src/render_workspace.rs": "e7ff050984f0f59d3dc44fa03b8076d48093f965048a74cb8aba0db9023d0a60",
"crates/ui-egui/src/render_workspace_cpu.rs": "a1534b73a972fffbfad50b831a00e3166c3d668ae7fb978b1286a3f390f4ade8",
"crates/ui-egui/src/render_workspace_interactive.rs": "0105efe25e758a750fcdad192d27f60f7af59b87030f0f6f7d1697bf7d593e76",
"crates/ui-egui/src/render_workspace_ribbon.rs": "27fc255da6da75976edb4b37928b78ea034c6c579f258b3155f15d43e3237ffb",
"crates/ui-egui/src/render_workspace_tests.rs": "bfc8bf587d1369a88eaa697620f071d5fbe3605d46cc92fc01c2096539a77b8a",
"docs/render-interactive.md": "df880685523b128fc42ae542567794ceef281839025a8ab16ffcef08d5cedc95"
},
"files": [
{
"path": "proposals/render/2026-10-29/README.md",
"bytes": 3893,
"sha256": "a8e974bb9b8ea67aeb6c84a183ee6d1bdf7a13668aebf44bed240a784ea36cf6",
"role": "Entry point and restart instructions"
},
{
"path": "proposals/render/2026-10-29/VALIDATION.md",
"bytes": 2441,
"sha256": "0639258173d1156ae3162c9477033dd9a299b5ab2ed538fca39b9f24433a4013",
"role": "Verified result summary with explicit source/qualification boundaries"
},
{
"path": "proposals/render/2026-10-29/interactive-working-changes.patch",
"bytes": 188555,
"sha256": "f97f6a976a61c8275fd31c7c0ca69a0b23108911591f1ea8b8e79761c5d89a38",
"role": "Apply once to exact parent a0779062 in a clean agreed scope; current local checkout already contains it"
},
{
"path": "proposals/render/2026-10-29/pending/cpu-area-led.patch",
"bytes": 34053,
"sha256": "317fee6c98e03758c933e3c51cb0aea5231fac46c5d9f80d2c8105efd79dfe9f",
"role": "Separate unapplied/uncompiled/unrun proposal; not part of Interactive source snapshot"
},
{
"path": "proposals/render/2026-10-29/pending/gpu-contract-tests.patch",
"bytes": 6405,
"sha256": "6888070fb9b6435d6bba803ba26a7b557c2b5b24a81de3eb1f2bb77c4326b68e",
"role": "Separate unapplied/uncompiled/unrun proposal; not part of Interactive source snapshot"
},
{
"path": "proposals/render/2026-10-29/pending/gpu-light-contract.json",
"bytes": 24690,
"sha256": "1d03c9fcabc107c5a3371011f30eaac665d2af8f7080d9c7a6b306b68abd0ec6",
"role": "Proposal only; new payload/ABI/caps pending owner decisions"
},
{
"path": "proposals/render/2026-10-29/pending/gpu-point-spot-refusal-tests.rs",
"bytes": 1510,
"sha256": "345de11965e16d26e7cfa3023e6599feb01564ae7981590990e032ff4f2acfd1",
"role": "Separate unapplied/uncompiled/unrun proposal; not part of Interactive source snapshot"
},
{
"path": "proposals/render/2026-10-29/pending/gpu-session-diagnostic.patch",
"bytes": 5117,
"sha256": "e8b5f05c9f4582f8733eb9c3911d64a8f232e981c81fe552c2314d1090a1cd19",
"role": "Separate unapplied/uncompiled/unrun proposal; not part of Interactive source snapshot"
},
{
"path": "proposals/render/2026-10-29/pending/punctual-analytic-tests.rs",
"bytes": 2752,
"sha256": "7237f3468ede92417dfd14c6f6849e86dd8fc4f1168a8c9188bce03a9bad3388",
"role": "Separate unapplied/uncompiled/unrun proposal; not part of Interactive source snapshot"
},
{
"path": "proposals/render/2026-10-29/pending/raster-view-direction.patch",
"bytes": 5356,
"sha256": "b0b3e9f4edfe41a05315b1dc722653dc2691efd5b62cc233e56027e9a98ab9e9",
"role": "Separate unapplied/uncompiled/unrun proposal; not part of Interactive source snapshot"
},
{
"path": "proposals/render/2026-10-29/pending/shadow-baseline-tests.patch",
"bytes": 12941,
"sha256": "23b9082ca1abf5f89e89a9d5ef4b38ee566c5aed8b08d8a18c2713e88448e448",
"role": "Separate unapplied/uncompiled/unrun proposal; not part of Interactive source snapshot"
},
{
"path": "proposals/render/2026-10-29/retired-do-not-apply/README.md",
"bytes": 345,
"sha256": "4941b7741d270aa43288fd7022ce3a8ae09b6375b01a9ff6715a2c85709cd0d0",
"role": "Retirement notice"
},
{
"path": "proposals/render/2026-10-29/retired-do-not-apply/plane-depth-candidate.patch",
"bytes": 2755,
"sha256": "31626aedd16c170fb544489e29a733dbf8d5e94ba2a5fe8fbe422bb1f4eb8f74",
"role": "RETIRED P1; DO NOT APPLY"
},
{
"path": "proposals/render/2026-10-29/source/assets/locales/pending/render-interactive.json",
"bytes": 10165,
"sha256": "4502966fe59189042d2420defa75091036599c0cb14d573027eb76fca1511a63",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/engine/src/cmd/mod.rs",
"bytes": 15081,
"sha256": "ee2a6307c9bc27436ab1d381e1994f44feb1336055d8f9c0bda9dcaa23cb4ebb",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/engine/src/cmd/render.rs",
"bytes": 40522,
"sha256": "6a1e3f963783adef24f2094e8c648e6d53ae1cc893c953a0ecc1bbb2067bfa24",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/engine/src/cmd/render/jobs.rs",
"bytes": 53584,
"sha256": "1cb96c6189fbbf9b904579d6c230aff004004249fcd89a86c3a82d87dc41bd68",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/engine/src/cmd/render_ui.rs",
"bytes": 17867,
"sha256": "45d0f233b6788de8f28037cc9ea4447aed19405334f6648dc60943c24672d020",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/engine/src/lib.rs",
"bytes": 91531,
"sha256": "3e1109f30b89552f7114276b5ca0fd5d335f845132bca798a091ce7d1a917701",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/engine/src/render_interactive.rs",
"bytes": 16244,
"sha256": "c6aecaed7a5f83dec1e158161854e9284213c938015df192566f4511b57ed7b0",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/engine/src/render_jobs.rs",
"bytes": 32277,
"sha256": "ca096820db7ecc6b5cd234d25ab9bdbb9f62e06b2ce876a2c8707d0838d6e5af",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/engine/src/schema/render_ui.rs",
"bytes": 905,
"sha256": "80793c893f50e4dc2f785ff5dd7652c1ef984a4f307e9225a6196203812b4653",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/render/src/job/host.rs",
"bytes": 83135,
"sha256": "7d2e248f8c7d6586f18d8b9840da9074514ed4672bc8a8845170a805b1bf9315",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/render/src/job/host_tests.rs",
"bytes": 55469,
"sha256": "0febf5af52d7aa24f3b0586e230752a17b6c0e30b33d79810c8b7338a34c941a",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/render/src/job/interactive.rs",
"bytes": 11088,
"sha256": "ccc8aaa5fc76038b39e8829f87b0888ccd92816567ea8b3d83778201364a4cce",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/ui-egui/src/command_help.rs",
"bytes": 15013,
"sha256": "861f005ae58c4d685ef8e2d30e372454607755a3eea3854b37f33147758930ba",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/ui-egui/src/render_ui/mod.rs",
"bytes": 13748,
"sha256": "d5599245102801e15d592ee28f069bdcb1f4afb1beaec3c5e85eeb5be8312df2",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/ui-egui/src/render_workspace.rs",
"bytes": 97904,
"sha256": "e7ff050984f0f59d3dc44fa03b8076d48093f965048a74cb8aba0db9023d0a60",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/ui-egui/src/render_workspace_cpu.rs",
"bytes": 27471,
"sha256": "a1534b73a972fffbfad50b831a00e3166c3d668ae7fb978b1286a3f390f4ade8",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/ui-egui/src/render_workspace_interactive.rs",
"bytes": 25844,
"sha256": "0105efe25e758a750fcdad192d27f60f7af59b87030f0f6f7d1697bf7d593e76",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/ui-egui/src/render_workspace_ribbon.rs",
"bytes": 13010,
"sha256": "27fc255da6da75976edb4b37928b78ea034c6c579f258b3155f15d43e3237ffb",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/crates/ui-egui/src/render_workspace_tests.rs",
"bytes": 404612,
"sha256": "bfc8bf587d1369a88eaa697620f071d5fbe3605d46cc92fc01c2096539a77b8a",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
},
{
"path": "proposals/render/2026-10-29/source/docs/render-interactive.md",
"bytes": 4048,
"sha256": "df880685523b128fc42ae542567794ceef281839025a8ab16ffcef08d5cedc95",
"role": "Exact current RAW working-source snapshot; combined candidate uncompiled/unrun"
}
]
}
+36
View File
@@ -0,0 +1,36 @@
# Render work checkpoint — 29 October 2026
Source-preservation checkpoint requested by Judit. Parent `a077906234972dbbf11e80c54893f85ece97c8b7`; production baseline remains unchanged on this checkpoint branch. **Nothing here is a ready-to-merge or feature-complete claim.**
## Integrated local candidate
`source/` holds all 20 changed files as exact RAW snapshots. `interactive-working-changes.patch` is their reviewable delta against the parent, including the three new files. It contains the corrected CPU lifecycle, immutable engine capture/identity, strict Interactive actions and nine-language text, cached binding proof, UI/provisional pixels, lazy GPU setup policy and original tests. The current local shared checkout already contains these changes: do not apply the patch there again.
For a clean continuation, read current repository/area instructions and coordination first, preserve existing WIP, agree the one shared checkout/sole builder, verify the exact base/preimages, and apply only the intended delta. `source/` is a backup, not permission to overwrite another contributor's newer files. Check `MANIFEST.json` hashes. The source uses existing dependencies and the unchanged parent Cargo.lock/config.
Read [VALIDATION.md](VALIDATION.md) before citing results. The combined candidate is uncompiled/unrun; a memory cancellation is not a code failure or a pass. No installed application was replaced. All local source remains preserved too.
## Pending separate units
| Proposal | State / dependency |
|---|---|
| CPU area/LED progressive | Independent source review complete; original 7 tests plus 1 positive core case written, unrun. Existing 25-catalog CPU help must be updated through the locale workflow before readiness. Keep Hiro's budget/profile work separate. |
| Raster perspective view direction | Independent mathematical/source review complete; CamWight and cfaulkingham confirmed it can proceed as its own small PR on main. Preserve their tile/clear and pixel-work admission changes. Three original tests unrun. |
| GPU session diagnostics | Metadata-only proposal; no backend/driver/recovery override or GPU qualification. |
| GPU light contract/tests | Point/Spot and multiple directionals need actual transport. Preserve true delta directional vs finite Sun; payload/ABI/caps are proposals, not existing APIs. Owner question pending in #6281. |
| Shadow baseline | Test-only, five new cases preserving four originals. All unrun; screenshot cause unproved. |
| Retired shadow production patch | **DO NOT APPLY.** Concrete admitted counterexample found in independent review. |
Apply only the chosen proposal once; alternatives are not cumulative feature support. No skipped tests, hidden fallback, relaxed assertion or blessed golden establishes completion.
## Public coordination
- [Current parent work plan and acceptance checklist](https://github.com/tangency-labs/solidcraft/issues/6201)
- [Existing draft core PR](https://github.com/tangency-labs/solidcraft/pull/8938)
- [Nathan-Dane: Interactive domain available](https://github.com/tangency-labs/solidcraft/pull/8000#issuecomment-6503761456)
- [Concrete adapter placement/ownership note](https://github.com/tangency-labs/solidcraft/pull/8000#issuecomment-6507877408)
- [GPU compiler prerequisites and measured limits](https://github.com/tangency-labs/solidcraft/pull/6850#issuecomment-6507173501)
- [Raster owner coordination](https://github.com/tangency-labs/solidcraft/pull/6483#issuecomment-6508034498)
- [GPU light-type/lowering owner question](https://github.com/tangency-labs/solidcraft/issues/6281#issuecomment-6508177026)
Drawings/SolidCore and historical runtime grants remain stopped; this checkpoint revives none of them. Preserve author ownership and existing panels/results/gizmos. Final repository gates and native mouse-only acceptance remain required before claiming delivery.
+16
View File
@@ -0,0 +1,16 @@
# Validation boundaries
The published feature PR remains #8938 at `a077906234972dbbf11e80c54893f85ece97c8b7`. This checkpoint is source preservation, not ready/merged delivery.
- B1V3: all 516 nonignored render-library tests passed once in separate exact-name processes; all ten Interactive tests included. Strict render Clippy all-targets and default-feature render wasm compile check passed. Actual native executable SHA-256: `3a0a69b35f14326f9e954c3eec582347cbb72572109b46d6b14acb6b87e78f86`.
- Those results apply to the exact core source recorded below. They do not qualify the engine/UI adapter, later tests or pending parity proposals.
- C2 combined engine/UI build was cancelled by the resource guard: available memory 2139308032 bytes crossed the reserved 2147483648-byte floor. No build-finished event, no executable artifact, no compiler error diagnostic, no binary listing and no test body. This is neither compiler PASS nor demonstrated product failure. Only owned compiler work was stopped; the user's SolidCraft was preserved.
- Current snapshot additionally contains the independently reviewed error-recovery/dimensions addendum and comment cleanup after C2. It has not been compiled or executed. UI: 19 original Interactive cases written (7 initial +4 real-worker acceptance +8 recovery/dimensions); unsupported-host refusal branches are not CPU-runtime passes.
- Previous broad repository CI is incomplete after GPU-safety cancellation. Its partial results cannot be transferred to this snapshot. No global CI/GPU/native-window acceptance claim.
- GPU owner recommends both #6813 and #6850 land first, then fresh source/resource admission and a single bounded serial device test. Discrete-GPU historical measurements are not Pegasus iGPU qualification.
- Further combined compilation needs fresh admission; observed C2 global memory fall was about 3.421 GiB, not a measured compiler-only peak. Sole builder proposes 8 GiB available, preferably 10 GiB, while keeping the 2-GiB reserve. No flags/profile/cache clean/copy workaround or automatic retry.
Core RAW source hashes preserved in this snapshot:
- `crates/render/src/job/host.rs`: `7d2e248f8c7d6586f18d8b9840da9074514ed4672bc8a8845170a805b1bf9315`
- `crates/render/src/job/host_tests.rs`: `0febf5af52d7aa24f3b0586e230752a17b6c0e30b33d79810c8b7338a34c941a`
- `crates/render/src/job/interactive.rs`: `ccc8aaa5fc76038b39e8829f87b0888ccd92816567ea8b3d83778201364a4cce`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,510 @@
--- a/crates/render/src/job/mod.rs
+++ b/crates/render/src/job/mod.rs
@@ -892,13 +892,10 @@
"CPU progressive requires primary_samples: 1 and one reflection attempt per camera sample",
));
}
- if !self.dielectrics.is_empty()
- || self.anisotropic_faces.iter().any(Option::is_some)
- || self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty())
- {
+ if !self.dielectrics.is_empty() || self.anisotropic_faces.iter().any(Option::is_some) {
return Err(JobError::new(
ErrorCode::InvalidRequest,
- "CPU progressive currently supports isotropic opaque surfaces and directional/point/spot lights; use fixed sampling for dielectric, anisotropic or area/LED inputs",
+ "CPU progressive currently supports isotropic opaque surfaces; use fixed sampling for dielectric or anisotropic inputs",
));
}
}
@@ -1187,7 +1184,7 @@
color: [1.0; 3],
intensity: 1.0,
};
- assert!(RaytraceOptions { progressive, area_lights: Some(vec![area]), ..Default::default() }.validate().is_err());
+ RaytraceOptions { progressive, area_lights: Some(vec![area]), ..Default::default() }.validate().unwrap();
let dielectric = DielectricOptions {
body_index: 0,
body_name: "Body".into(),
@@ -1209,4 +1206,76 @@
assert!(RaytraceOptions { progressive, anisotropic_faces: [Some(face), None], ..Default::default() }.validate().is_err());
RaytraceOptions { progressive, area_lights: Some(Vec::new()), ..Default::default() }.validate().unwrap();
}
-}
+
+ #[test]
+ fn progressive_rectangle_and_led_admission_preserves_source_limits_and_atomic_rejection() {
+ let strip = led_strip::LedStripOptions {
+ center_mm: [0.0, 0.0, 10.0],
+ length_mm: 2.0,
+ width_mm: 2.0,
+ length_axis: [1.0, 0.0, 0.0],
+ width_axis: [0.0, -1.0, 0.0],
+ color: [1.0, 0.5, 0.25],
+ intensity: 2.0,
+ };
+ for samples in [1, 4, 16, 64] {
+ let mut options = RaytraceOptions {
+ progressive: Some(CpuProgressiveOptions { samples_per_pixel: 3, seed: 97 }),
+ lights: Some(Vec::new()),
+ ..Default::default()
+ };
+ for count in 1..=4 {
+ strip.append_to(&mut options, samples).unwrap();
+ options.validate().unwrap();
+ assert_eq!(options.area_lights.as_ref().unwrap().len(), count);
+ assert_eq!(
+ options.retained_bytes().unwrap(),
+ (options.area_lights.as_ref().unwrap().capacity() * std::mem::size_of::<RectangleLightOptions>()) as u64
+ );
+ }
+ let before = options.clone();
+ let capacity = options.area_lights.as_ref().unwrap().capacity();
+ assert!(strip.append_to(&mut options, samples).is_err());
+ assert_eq!(options, before);
+ assert_eq!(options.area_lights.as_ref().unwrap().capacity(), capacity);
+ options.area_lights.as_mut().unwrap().pop();
+ let before = options.clone();
+ let conflicting = if samples == 1 { 4 } else { 1 };
+ assert!(strip.append_to(&mut options, conflicting).is_err());
+ assert_eq!(options, before);
+ for invalid in [0, 2, 65, u32::MAX] {
+ let mut hostile = options.clone();
+ hostile.area_samples = Some(invalid);
+ assert!(hostile.validate().is_err());
+ }
+ let mut hostile = options.clone();
+ hostile.area_lights.as_mut().unwrap()[0].intensity = f64::NAN;
+ assert!(hostile.validate().is_err());
+ }
+ let mut maximum = RaytraceOptions {
+ progressive: Some(CpuProgressiveOptions::default()),
+ lights: Some(vec![DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 1.0 }; 8]),
+ punctual_lights: Some(vec![PunctualLightOptions {
+ position_mm: [0.0, 0.0, 10.0],
+ color: [1.0; 3],
+ intensity: 1.0,
+ reference_distance_mm: 10.0,
+ minimum_distance_mm: 0.001,
+ cone: None,
+ }; 8]),
+ ..Default::default()
+ };
+ for _ in 0..4 {
+ strip.append_to(&mut maximum, 16).unwrap();
+ }
+ maximum.validate().unwrap();
+ maximum.area_lights.as_mut().unwrap().push(strip.rectangle().unwrap());
+ assert!(maximum.validate().is_err());
+ for options in [
+ RaytraceOptions { progressive: Some(CpuProgressiveOptions::default()), area_samples: Some(16), ..Default::default() },
+ RaytraceOptions { progressive: Some(CpuProgressiveOptions::default()), seed: Some(7), ..Default::default() },
+ ] {
+ assert!(options.validate().is_err());
+ }
+ }
+}
--- a/crates/render/src/raytrace/primary_aa_tests.rs
+++ b/crates/render/src/raytrace/primary_aa_tests.rs
@@ -489,3 +489,227 @@
assert!(area_sample_key(41, 4, 4, 0).is_err());
assert!(area_sample_key(u64::MAX, 0, 4, 0).is_err());
}
+
+
+// Original receiver and rectangular source; no borrowed model or recorded image.
+fn progressive_area_fixture(area_samples: u32, samples: u32, seed: u32, shadows: bool) -> ImageTask {
+ let original = fixture(1, 1, Some(true));
+ let mut scene = original.scene.clone();
+ scene.meshes[0].mesh = Arc::new(Mesh {
+ positions: vec![Vec3::new(-100.0, -100.0, 0.0), Vec3::new(100.0, -100.0, 0.0), Vec3::new(100.0, 100.0, 0.0), Vec3::new(-100.0, 100.0, 0.0)],
+ normals: vec![Vec3::Z; 4],
+ triangles: vec![[0, 1, 2], [0, 2, 3]],
+ tri_face: vec![0; 2],
+ ..Default::default()
+ });
+ scene.meshes[0].surface = Some(OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), 0.0, 1.0, 1.0).unwrap());
+ scene.bounds = scene.meshes[0].mesh.bounds();
+ let options = RaytraceOptions {
+ progressive: Some(job::CpuProgressiveOptions { samples_per_pixel: samples, seed: 97 }),
+ lights: Some(Vec::new()),
+ area_lights: Some(vec![job::RectangleLightOptions {
+ center_mm: [0.0, 0.0, 10.0],
+ edge_u_mm: [2.0, 0.0, 0.0],
+ edge_v_mm: [0.0, -2.0, 0.0],
+ color: [1.0, 0.5, 0.25],
+ intensity: 2.0,
+ }]),
+ area_samples: Some(area_samples),
+ seed: Some(seed),
+ shadows,
+ ..Default::default()
+ };
+ let request = ImageRequest { raytrace: Some(options.clone()), ..original.request.clone() };
+ ImageTask::new(scene, request, CancelToken::default(), options).unwrap()
+}
+
+fn progressive_area_rebuild(source: &ImageTask, options: RaytraceOptions) -> ImageTask {
+ let request = ImageRequest { raytrace: Some(options.clone()), ..source.request.clone() };
+ ImageTask::new(source.scene.clone(), request, CancelToken::default(), options).unwrap()
+}
+
+#[test]
+fn progressive_area_transport_uses_absolute_film_ordinal_and_seed_for_all_sample_counts() {
+ use super::super::area::RectangleLight;
+ for count in [1, 4, 16, 64] {
+ for seed in [7, 91] {
+ let mut task = progressive_area_fixture(count, 2, seed, false);
+ let light = RectangleLight::new(Vec3::Z * 10.0, Vec3::X * 2.0, -Vec3::Y * 2.0, Vec3::new(1.0, 0.5, 0.25), 2.0).unwrap();
+ let mut inspected = 0;
+ let mut first_positions = Vec::new();
+ let mut complete = false;
+ for _ in 0..200_000 {
+ let ordinal = task.progressive.as_ref().unwrap().film.samples();
+ let before = task.pending.as_ref().filter(|p| p.hit.is_some() && p.area_light == 0 && p.area_index < count).map(|p| (p.area_index, p.ray));
+ let poll = task.step_with_previews(1).unwrap();
+ if let Some((index, ray)) = before
+ && let Some(pending) = task.pending.as_ref()
+ && pending.area_light == 0
+ && pending.area_index == index + 1
+ {
+ let point = ray.origin() + ray.direction() * (-ray.origin().z / ray.direction().z);
+ let key = area_sample_key(0, 0, 1, ordinal).unwrap();
+ let sampled = light.sample(point, key, 0, index, count, seed).unwrap().unwrap();
+ // Index-matched nonmetallic white receiver is exactly Lambertian.
+ // Check the actual consumer, independently of its BRDF and area weight.
+ let delta = sampled.position() - point;
+ let distance_squared = delta.dot(delta);
+ let factor = 4.0 * delta.z * delta.z / (distance_squared * distance_squared * f64::from(count) * std::f64::consts::PI);
+ let expected = Vec3::new(2.0, 1.0, 0.5) * factor;
+ assert!((pending.area_value - expected).len() < 1e-12, "N={count}, seed={seed}, film={ordinal}, index={index}");
+ if index == 0 {
+ first_positions.push(sampled.position());
+ }
+ inspected += 1;
+ }
+ if matches!(poll, ImagePoll::Complete(_)) {
+ complete = true;
+ break;
+ }
+ }
+ assert!(complete);
+ assert_eq!(inspected, 2 * count);
+ assert_eq!(first_positions.len(), 2);
+ if count == 1 {
+ assert_eq!(first_positions[0], first_positions[1], "one sample intentionally stays at the emitter centre");
+ } else {
+ assert_ne!(first_positions[0], first_positions[1], "later film passes must not reuse the first emitter point");
+ }
+ }
+ }
+}
+
+#[test]
+fn progressive_area_center_sample_resolves_the_analytic_linear_mean_once() {
+ let task = progressive_area_fixture(1, 3, 7, false);
+ let camera = task.request.camera;
+ let mut sum = Vec3::ZERO;
+ for ordinal in 0..3 {
+ let [u, v] = progressive::variates(97, 0, ordinal, 0);
+ let (origin, direction) = camera.ray(u, v, 1.0, 1.0);
+ let point = origin + direction * (-origin.z / direction.z);
+ let delta = Vec3::Z * 10.0 - point;
+ let r2 = delta.dot(delta);
+ sum += Vec3::new(2.0, 1.0, 0.5) * (4.0 * delta.z * delta.z / (r2 * r2 * std::f64::consts::PI));
+ }
+ let mean = sum / 3.0;
+ let expected = [mean.x, mean.y, mean.z].map(|value| encode8(value / (1.0 + value)));
+ let (_, png, _) = progressive_complete(task, 1);
+ assert_eq!(::image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0, [expected[0], expected[1], expected[2], 255]);
+}
+
+#[test]
+fn progressive_area_checkpoints_and_work_are_independent_of_target_and_quantum() {
+ for count in [1, 4, 16, 64] {
+ let small = progressive_complete(progressive_area_fixture(count, 5, 23, true), 1);
+ let large = progressive_complete(progressive_area_fixture(count, 5, 23, true), 16_384);
+ assert_eq!(small, large, "area continuation must share the same cumulative work across quanta");
+ assert_eq!(small.0.iter().map(|(samples, _)| *samples).collect::<Vec<_>>(), [1, 2, 4]);
+ for (samples, checkpoint) in &small.0 {
+ let (_, final_png, _) = progressive_complete(progressive_area_fixture(count, *samples, 23, true), 3);
+ assert_eq!(&final_png, checkpoint);
+ }
+ }
+}
+
+#[test]
+fn progressive_led_lowering_keeps_rectangle_power_sampling_and_final_bytes() {
+ let strip = job::led_strip::LedStripOptions {
+ center_mm: [0.0, 0.0, 10.0],
+ length_mm: 2.0,
+ width_mm: 2.0,
+ length_axis: [1.0, 0.0, 0.0],
+ width_axis: [0.0, -1.0, 0.0],
+ color: [1.0, 0.5, 0.25],
+ intensity: 2.0,
+ };
+ for count in [1, 4, 16, 64] {
+ let direct = progressive_area_fixture(count, 3, 7, true);
+ let mut options = direct.options.clone();
+ options.area_lights = None;
+ options.area_samples = None;
+ options.seed = None;
+ strip.append_to(&mut options, count).unwrap();
+ options.seed = direct.options.seed;
+ assert_eq!(options, direct.options);
+ let lowered = progressive_area_rebuild(&direct, options);
+ assert_eq!(progressive_complete(direct, 1), progressive_complete(lowered, 7));
+ }
+}
+
+#[test]
+fn progressive_area_admission_bills_source_capacity_and_reuses_bounded_scratch() {
+ let source = progressive_area_fixture(1, 3, 7, true);
+ let mut empty = source.options.clone();
+ empty.area_lights = None;
+ empty.area_samples = None;
+ empty.seed = None;
+ let baseline = progressive_area_rebuild(&source, empty);
+ let slots = source.options.area_lights.as_ref().unwrap().capacity();
+ let bytes = (slots * std::mem::size_of::<job::RectangleLightOptions>()) as u64;
+ assert_eq!(source.options.retained_bytes().unwrap(), bytes);
+ assert_eq!(source.admission.estimated_total_bytes - baseline.admission.estimated_total_bytes, bytes);
+ assert_eq!(source.admission.preparation_bytes - baseline.admission.preparation_bytes, bytes);
+ for count in [4, 16, 64] {
+ let more = progressive_area_fixture(count, 3, 7, true);
+ assert_eq!(more.admission.estimated_total_bytes, source.admission.estimated_total_bytes, "area samples reuse the same continuation, not an N-sized buffer");
+ }
+ let mut over = source.request.clone();
+ over.width = 1025;
+ over.height = 1024;
+ assert_eq!(admit(&source.scene, &over, &source.options).err().unwrap().code, ErrorCode::LimitExceeded);
+}
+
+#[test]
+fn progressive_area_shadow_cancellation_and_checkpoint_do_not_reset_work() {
+ let mut task = progressive_area_fixture(16, 3, 7, true);
+ let mut pending_shadow = false;
+ for _ in 0..200_000 {
+ if task.pending.as_ref().is_some_and(|p| p.area_shadow) {
+ pending_shadow = true;
+ break;
+ }
+ assert!(matches!(task.step_with_previews(1).unwrap(), ImagePoll::Pending(_)));
+ }
+ assert!(pending_shadow);
+ let used = task.work.used();
+ task.cancel.cancel();
+ assert_eq!(task.step_with_previews(1).err().unwrap().code, ErrorCode::Cancelled);
+ assert_eq!(task.work.used(), used);
+ assert_eq!(task.progressive.as_ref().unwrap().film.samples(), 0);
+ assert!(!task.done);
+
+ let mut reference = progressive_area_fixture(16, 3, 7, true);
+ let mut checkpoint_work = None;
+ for _ in 0..200_000 {
+ if let ImagePoll::Preview { samples_per_pixel, .. } = reference.step_with_previews(1).unwrap() {
+ assert_eq!(samples_per_pixel, 1);
+ checkpoint_work = Some(reference.work.used());
+ break;
+ }
+ }
+ let limit = checkpoint_work.unwrap();
+ let mut bounded = progressive_area_fixture(16, 3, 7, true);
+ bounded.work = WorkBudget::new(limit).unwrap();
+ let mut observed_checkpoint = false;
+ for _ in 0..200_000 {
+ match bounded.step_with_previews(1) {
+ Ok(ImagePoll::Pending(_)) => {}
+ Ok(ImagePoll::Preview { samples_per_pixel, .. }) => {
+ assert!(!observed_checkpoint);
+ assert_eq!(samples_per_pixel, 1);
+ observed_checkpoint = true;
+ assert_eq!(bounded.work.used(), limit);
+ }
+ Ok(ImagePoll::Complete(_)) => panic!("work exhausted after the checkpoint must not publish a final"),
+ Err(error) => {
+ assert_eq!(error.code, ErrorCode::LimitExceeded);
+ assert!(observed_checkpoint);
+ assert_eq!(bounded.work.used(), limit);
+ assert!(!bounded.done);
+ return;
+ }
+ }
+ }
+ panic!("area work ceiling was reset or the fixture did not finish");
+}
--- a/crates/render/src/job/host_tests.rs
+++ b/crates/render/src/job/host_tests.rs
@@ -46,13 +46,17 @@
#[test]
fn preview_refuses_incompatible_transport_and_invalid_input_without_erasing_it() {
let mut source = cpu();
- source.raytrace.as_mut().unwrap().area_lights = Some(vec![crate::job::RectangleLightOptions {
- center_mm: [0.0, 0.0, 10.0],
- edge_u_mm: [2.0, 0.0, 0.0],
- edge_v_mm: [0.0, 2.0, 0.0],
- color: [1.0; 3],
- intensity: 2.0,
- }]);
+ // A valid fixed thin sheet remains deliberately unsupported by progressive.
+ // Area/LED now has its positive preservation case below.
+ source.raytrace.as_mut().unwrap().dielectrics = vec![crate::job::DielectricOptions {
+ body_index: 0,
+ body_name: "Original sheet".into(),
+ boundary: crate::job::DielectricBoundary::ThinSheet,
+ ior: 1.5,
+ absorption_per_mm: [0.0; 3],
+ thickness_mm: 1.0,
+ }];
+ source.raytrace.as_ref().unwrap().validate().unwrap();
let before = serde_json::to_value(&source).unwrap();
assert!(cpu_request(&source).is_err());
assert_eq!(serde_json::to_value(&source).unwrap(), before);
@@ -63,6 +67,84 @@
ImageRequest { raytrace: Some(RaytraceOptions { primary_samples: 3, ..Default::default() }), ..cpu() },
] {
assert!(cpu_request(&source).is_err());
+ }
+ }
+
+
+ #[test]
+ fn preview_preserves_rectangle_and_led_sources_and_all_nonsampling_options() {
+ use crate::job::{
+ CpuProgressiveOptions, DirectionalLightOptions, PunctualLightOptions, RaytraceEnvironmentOptions,
+ RectangleLightOptions, ReflectionBody, ReflectionMode, ReflectionOptions, led_strip::LedStripOptions,
+ };
+ let rectangle = RectangleLightOptions {
+ center_mm: [0.0, 0.0, 10.0],
+ edge_u_mm: [2.0, 0.0, 0.0],
+ edge_v_mm: [0.0, -2.0, 0.0],
+ color: [1.0, 0.5, 0.25],
+ intensity: 2.0,
+ };
+ let led = LedStripOptions {
+ center_mm: [5.0, 0.0, 10.0],
+ length_mm: 8.0,
+ width_mm: 0.5,
+ length_axis: [1.0, 0.0, 0.0],
+ width_axis: [0.0, -1.0, 0.0],
+ color: [0.25, 0.5, 1.0],
+ intensity: 3.0,
+ };
+ for area_samples in [1, 4, 16, 64] {
+ let mut source = cpu();
+ source.width = 3840;
+ source.height = 2160;
+ source.camera.roll = 0.37;
+ source.background = [19, 31, 97];
+ let mut options = RaytraceOptions {
+ primary_samples: 16,
+ exposure_ev: 1.25,
+ shadows: false,
+ lights: Some(vec![DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 1.0 }]),
+ punctual_lights: Some(vec![PunctualLightOptions {
+ position_mm: [0.0, 0.0, 20.0],
+ color: [1.0; 3],
+ intensity: 2.0,
+ reference_distance_mm: 10.0,
+ minimum_distance_mm: 0.01,
+ cone: None,
+ }]),
+ area_lights: Some(vec![rectangle.clone()]),
+ area_samples: Some(area_samples),
+ seed: Some(73),
+ environment: Some(RaytraceEnvironmentOptions { color: [0.2, 0.3, 0.4], intensity: 2.0 }),
+ reflections: Some(ReflectionOptions {
+ bodies: vec![ReflectionBody { body: "Subject".into(), mode: ReflectionMode::RoughSpecular }],
+ samples: 16,
+ max_bounces: 3,
+ }),
+ ..Default::default()
+ };
+ led.append_to(&mut options, area_samples).unwrap();
+ options.validate().unwrap();
+ source.raytrace = Some(options);
+ let before = serde_json::to_value(&source).unwrap();
+ let mut expected = source.clone();
+ expected.width = 1024;
+ expected.height = 576;
+ let expected_options = expected.raytrace.as_mut().unwrap();
+ expected_options.primary_samples = 1;
+ expected_options.progressive = Some(CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 });
+ expected_options.reflections.as_mut().unwrap().samples = 1;
+
+ let preview = cpu_request(&source).unwrap();
+ assert_eq!(serde_json::to_value(&preview).unwrap(), serde_json::to_value(&expected).unwrap(),
+ "all request fields must survive except the explicitly chosen preview envelope");
+ assert_eq!(preview.raytrace, expected.raytrace);
+ assert!(preview.environment.is_none() && preview.textures.is_none());
+ let preview_options = preview.raytrace.as_ref().unwrap();
+ assert_eq!(preview_options.area_lights.as_ref().unwrap().as_slice(), &[rectangle.clone(), led.rectangle().unwrap()]);
+ assert_eq!(preview_options.area_samples, Some(area_samples));
+ assert_eq!(preview_options.seed, Some(73), "camera seed zero must not replace the area-pattern seed");
+ assert_eq!(serde_json::to_value(&source).unwrap(), before, "the final request remains immutable");
}
}
--- a/docs/render-cpu-progressive.md
+++ b/docs/render-cpu-progressive.md
@@ -73,12 +73,20 @@
| Directional / point / spot sources | Existing CPU light contracts and limits |
| Constant / captured HDR environment | Existing CPU contracts; HDR plus rough reflections still requires certified fully metallic selected bodies and effective faces |
| Surface emission / isotropic surface effects | Existing CPU appearance contracts; emission is visible radiance |
-| Area / LED sources | Explicitly rejected in this initial progressive mode |
+| Area / LED sources | Existing bounded rectangle direct-light transport; continuous LED recipes lower to the same sources |
| Dielectric / anisotropic face overrides | Explicitly rejected in this initial progressive mode |
| Output | Opaque PNG with the existing display background |
An incompatible request fails; it does not remove settings or switch methods.
The fixed CPU route retains its existing area, dielectric and anisotropy support.
+Progressive area/LED uses at most four shared rectangle slots, with
+`area_samples` of 1/4/16/64 (default 16) and the existing combined light caps.
+Each film pass uses the existing area-power normalization and cumulative work
+budget; increasing emitter samples does not multiply emitted power. The area
+pattern uses `raytrace.seed`, separately from the camera-jitter seed
+`raytrace.progressive.seed`. One emitter sample still uses its centre. This is
+direct lighting, with no MIS or diffuse GI; general glass and anisotropic
+progressive transport remain unsupported. Host-pool and profile limits are unchanged.
At 1024 × 1024 the camera-sample cap allows at most 16 samples per pixel; at
512 × 512 it allows 64. Requests are also subject to the existing geometry,
384 MiB CPU job and 512 MiB shared host-pool admission limits. Traversal,
--- a/docs/render-settings.md
+++ b/docs/render-settings.md
@@ -162,9 +162,10 @@
empty object defaults to 16 samples and seed 0. Samples are 1..64 and the seed is
u32. `primary_samples` must be 1, output is at most 1,048,576 pixels, and
`width × height × samples_per_pixel` is at most 16,777,216. Saving checks these
-limits without a render host. Area/LED lights, dielectric and anisotropic-face
-overrides are refused in this mode; the fixed CPU route retains them. Embedded
-HDR and one setup exposure remain compatible. See
+limits without a render host. Area/LED direct lights retain the existing four
+shared rectangle slots, emitter-sample counts and power normalization. Dielectric
+and anisotropic-face overrides remain refused; the fixed CPU route retains them.
+Embedded HDR and one setup exposure remain compatible. See
[CPU progressive sampling](render-cpu-progressive.md) for reflection limits,
work admission and provisional/final publication. The top-level runtime
`progressive: true` continues to select GPU.
--- a/docs/render-interactive.md
+++ b/docs/render-interactive.md
@@ -14,6 +14,9 @@
(or one pixel for an extreme aspect). Transparent output, another integrator or
unsupported progressive transport returns an error instead of dropping effects.
The existing profile and host still enforce geometry, traversal and memory limits.
+Rectangle/LED direct-light inputs, their emitter-sample count and area-pattern
+seed survive preview lowering unchanged. Only camera/reflection sampling uses
+the preview envelope; dielectric and anisotropic overrides still fail explicitly.
## Adapter contract
--- a/crates/engine/src/cmd/render.rs
+++ b/crates/engine/src/cmd/render.rs
@@ -124,7 +124,7 @@
CommandSpec::new("render.settings.capture_hdr", "Save Render Settings with HDR", resources::capture_settings).icon("settings").doc_only()
.params("name, set: typed CPU/GPU setup patch with hdr/environment unset; path: scoped HDR; expected_sha256?: digest returned by render.hdr.inspect (changed sources refuse); rotation_rad?:0, intensity?:1; samples?:1/4/16/64 for CPU only (default16); assume_linear_srgb?:false for GPU only. Captures immutable bytes and saves resource plus setup atomically in one undo, without a host; returns name/setup/resource metadata, never source bytes"),
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only()
- .params("setup?: saved name OR settings?: full RenderSetup. A typed job selects raster/cpu_raytrace/gpu_path and its numerical options plus embedded HDR before dispatch; only replace and transient studio_weights may accompany it. Without job, the legacy runtime controls below remain available. See docs/render-settings.md. studio_weights?: three finite 0..1 visibility weights for raster or implicit GPU studio sources only; never saved. integrator?: raster (default) or raytrace; progressive?: false (registered GPU path, CPU integrator/LED options are mutually exclusive); samples?: 1..4096; max_depth?: 1..16; seed?: u32 for progressive; raytrace?: bounded runtime progressive/primary_samples/lights/punctual_lights/area_lights/area_samples/seed/exposure_ev/shadows/reflections/anisotropic_faces/emissions/surface_effects/dielectrics/environment options, only with raytrace. CPU raytrace.budget?: compact (legacy default), studio or workstation; see docs/render-cpu-resource-profiles.md for explicit image/geometry/memory/work limits. CPU raytrace.progressive?: {samples_per_pixel:1..profile limit, seed:u32}; requires primary_samples:1 and selected reflections.samples:1; no area/LED, anisotropy or dielectrics in this initial mode. See docs/render-cpu-progressive.md; environment?: {path: scoped HDR, expected_sha256?: inspected digest, rotation_radians: 0, intensity: 1, samples: 16} OR {resource: saved HDR digest, rotation_radians, intensity, samples}; embedded resources cannot combine with path/hash; explicit sRGB/D65 HDR primaries required. Opaque CPU preview with selected mirror/rough-specular reflections and certified planar anisotropy. HDR with rough reflections or anisotropy requires fully metallic selected bodies and all effective faces; unsupported combinations refuse. No MIS or GI. CPU runtime dielectrics support only certified thin_sheet/closed_box boundaries with explicit IOR and Beer absorption; see docs/render-raytrace-dielectrics.md. CPU emissions are visible radiance only; surface_effects change roughness/shading normals without geometry changes. led_strip?: continuous world-mm LED rectangle recipe; led_samples?: 1/4/16/64 for the whole strip, default16. At most8directional+8punctual+4rectangle/LED sources; shared area N. See docs/render-light-union.md. transparent_background?: false; replace?: false. With progressive:true, raytrace instead accepts GPU dielectrics/clear_coats/textures; environment instead accepts tagged hdr/embedded_hdr/sky with rotation_rad/intensity; file hdr supports expected_sha256, embedded_hdr uses a document resource digest. See docs/render-hdr-resources.md. GPU lens, studio lighting, tone_map, denoise, bounded finishing/backdrop and linear_exr retention are also available. GPU adaptive?: {pilot_samples:16..256,min_samples:16..samples,relative_error:0..1,absolute_error:0.000001..1000000,coverage_error:0.001..0.25} allocates independent production budgets; pilots are discarded, not a certified error guarantee. transmission_samples?:1|2|4 stratifies the first Fresnel branch with extra path cost. volume?:{min_mm:[x,y,z],max_mm:[x,y,z],extinction_per_mm:0..1000,scattering_albedo:[r,g,b],anisotropy:-0.95..0.95} is one finite homogeneous world-mm medium; active media require explicit lighting/environment and refuse glass, denoise and multiple transmission strata. These fields also persist in GPU job.options. See docs/render-materials/TRANSPORT-MASTER-BATCH-2026-10-23.md; see docs/render-transmission.md, render-environment.md, render-runtime-textures.md, render-thin-lens.md, render-studio-lighting.md and render-denoise.md. Optional CPU raytracing requires a raytrace-cpu build and native host, checked before scene capture. Stale geometry is rejected. See docs/render-jobs.md"),
+ .params("setup?: saved name OR settings?: full RenderSetup. A typed job selects raster/cpu_raytrace/gpu_path and its numerical options plus embedded HDR before dispatch; only replace and transient studio_weights may accompany it. Without job, the legacy runtime controls below remain available. See docs/render-settings.md. studio_weights?: three finite 0..1 visibility weights for raster or implicit GPU studio sources only; never saved. integrator?: raster (default) or raytrace; progressive?: false (registered GPU path, CPU integrator/LED options are mutually exclusive); samples?: 1..4096; max_depth?: 1..16; seed?: u32 for progressive; raytrace?: bounded runtime progressive/primary_samples/lights/punctual_lights/area_lights/area_samples/seed/exposure_ev/shadows/reflections/anisotropic_faces/emissions/surface_effects/dielectrics/environment options, only with raytrace. CPU raytrace.budget?: compact (legacy default), studio or workstation; see docs/render-cpu-resource-profiles.md for explicit image/geometry/memory/work limits. CPU raytrace.progressive?: {samples_per_pixel:1..profile limit, seed:u32}; requires primary_samples:1 and selected reflections.samples:1; bounded area/LED direct lights retain their existing source and emitter-sample limits; anisotropy and dielectrics require fixed sampling. See docs/render-cpu-progressive.md; environment?: {path: scoped HDR, expected_sha256?: inspected digest, rotation_radians: 0, intensity: 1, samples: 16} OR {resource: saved HDR digest, rotation_radians, intensity, samples}; embedded resources cannot combine with path/hash; explicit sRGB/D65 HDR primaries required. Opaque CPU preview with selected mirror/rough-specular reflections and certified planar anisotropy. HDR with rough reflections or anisotropy requires fully metallic selected bodies and all effective faces; unsupported combinations refuse. No MIS or GI. CPU runtime dielectrics support only certified thin_sheet/closed_box boundaries with explicit IOR and Beer absorption; see docs/render-raytrace-dielectrics.md. CPU emissions are visible radiance only; surface_effects change roughness/shading normals without geometry changes. led_strip?: continuous world-mm LED rectangle recipe; led_samples?: 1/4/16/64 for the whole strip, default16. At most8directional+8punctual+4rectangle/LED sources; shared area N. See docs/render-light-union.md. transparent_background?: false; replace?: false. With progressive:true, raytrace instead accepts GPU dielectrics/clear_coats/textures; environment instead accepts tagged hdr/embedded_hdr/sky with rotation_rad/intensity; file hdr supports expected_sha256, embedded_hdr uses a document resource digest. See docs/render-hdr-resources.md. GPU lens, studio lighting, tone_map, denoise, bounded finishing/backdrop and linear_exr retention are also available. GPU adaptive?: {pilot_samples:16..256,min_samples:16..samples,relative_error:0..1,absolute_error:0.000001..1000000,coverage_error:0.001..0.25} allocates independent production budgets; pilots are discarded, not a certified error guarantee. transmission_samples?:1|2|4 stratifies the first Fresnel branch with extra path cost. volume?:{min_mm:[x,y,z],max_mm:[x,y,z],extinction_per_mm:0..1000,scattering_albedo:[r,g,b],anisotropy:-0.95..0.95} is one finite homogeneous world-mm medium; active media require explicit lighting/environment and refuse glass, denoise and multiple transmission strata. These fields also persist in GPU job.options. See docs/render-materials/TRANSPORT-MASTER-BATCH-2026-10-23.md; see docs/render-transmission.md, render-environment.md, render-runtime-textures.md, render-thin-lens.md, render-studio-lighting.md and render-denoise.md. Optional CPU raytracing requires a raytrace-cpu build and native host, checked before scene capture. Stale geometry is rejected. See docs/render-jobs.md"),
CommandSpec::new("render.start", "Render Image", jobs::start).key("R").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()
@@ -0,0 +1,116 @@
--- a/crates/engine/src/cmd/render/jobs.rs
+++ b/crates/engine/src/cmd/render/jobs.rs
@@ -123,6 +123,113 @@
assert!(input.led_strip.is_none() && input.led_samples.is_none());
assert_eq!(wire["environment"]["path"], json!("unopened.hdr"));
}
+ // These contract tests never register a host, read a resource file or create a device.
+ #[test]
+ fn portable_gpu_extensions_survive_request_normalization_without_an_executor() {
+ let s = Session::default();
+ let camera = solidcraft_render::Camera { fov: 0.8, ..Default::default() };
+ let settings = json!({
+ "width":16,"height":8,"camera":camera,"exposure_ev":1.25,
+ "job":{"method":"gpu_path","options":{
+ "samples":96,"max_depth":11,"seed":1234,"denoise":false,"tone_map":"aces_fitted",
+ "nested_media":true,"transmission_samples":1,
+ "volume":{"min_mm":[-20.0,-10.0,-5.0],"max_mm":[20.0,10.0,5.0],
+ "extinction_per_mm":0.002,"scattering_albedo":[0.2,0.4,0.7],"anisotropy":0.3},
+ "adaptive":{"pilot_samples":16,"min_samples":32,"relative_error":0.08,
+ "absolute_error":0.003,"coverage_error":0.02},
+ "finishing":{"rgb_gain":[1.2,0.8,1.1],"contrast":1.3,"saturation":0.7,"vignette":0.2,
+ "bloom":{"strength":0.15,"threshold":2.0,"radius":7}},
+ "backdrop":{"mode":"radial","first_srgb":[10,20,30],"second_srgb":[80,90,100],
+ "center":[0.25,0.75],"radius":0.8},
+ "linear_exr":true,"lens":{"aperture_radius_mm":0.25,"focus_distance_mm":42.0},
+ "lighting":{"sun":null,"environment":[0.2,0.4,0.6],"area_lights":[]}
+ }}
+ });
+ let input = json!({"settings":settings,"replace":true});
+ let before = s.doc.to_json();
+ let revision = s.revision;
+ let normalized = normalize_saved_job(&s, &input, "render.preflight").unwrap().unwrap();
+ assert_eq!(normalized.params["progressive"], true);
+ assert_eq!(normalized.params["replace"], true);
+ assert!(normalized.params["settings"]["job"].is_null());
+ assert_eq!(normalized.params["settings"]["exposure_ev"], 1.25);
+ for field in ["samples","max_depth","seed","denoise","tone_map","nested_media","transmission_samples",
+ "volume","adaptive","finishing","backdrop","linear_exr","lens","lighting"] {
+ assert_eq!(normalized.params[field], settings["job"]["options"][field], "lost portable field {field}");
+ }
+ let lowered: ProgressiveStart = serde_json::from_value(normalized.params).unwrap();
+ assert!(lowered.progressive && lowered.linear_exr && lowered.nested_media);
+ assert_eq!(lowered.volume.unwrap().extinction_per_mm, 0.002);
+ assert_eq!(lowered.adaptive.unwrap().min_samples, 32);
+ assert_eq!(lowered.finishing.bloom.unwrap().radius, 7);
+ assert_eq!(lowered.lens.unwrap().focus_distance_mm, 42.0);
+ assert_eq!(input, json!({"settings":settings,"replace":true}));
+ assert_eq!(s.doc.to_json(), before);
+ assert_eq!(s.revision, revision);
+ assert!(s.render_job_host().is_none());
+ }
+
+ #[test]
+ fn portable_gpu_intent_rejects_runtime_extension_overrides_before_resource_capture() {
+ let s = Session::default();
+ let original = json!({"settings":{"job":{"method":"gpu_path"}}});
+ let before = s.doc.to_json();
+ for (key, value) in [
+ ("volume", json!({})), ("adaptive", json!({})), ("nested_media", json!(false)),
+ ("transmission_samples", json!(1)), ("aging", json!([])),
+ ("finishing", json!({})), ("linear_exr", json!(false)), ("backdrop", json!(null)),
+ ("lens", json!(null)), ("denoise", json!(false)),
+ ("environment", json!({"kind":"hdr","path":"must-never-be-read.hdr"})),
+ ] {
+ let mut mixed = original.clone();
+ mixed[key] = value;
+ for command in ["render.preflight", "render.start"] {
+ let error = match normalize_saved_job(&s, &mixed, command) {
+ Err(error) => error.to_string(),
+ Ok(_) => panic!("runtime extension {key} overrode portable GPU intent"),
+ };
+ assert!(error.contains("cannot override"), "{key}: {error}");
+ }
+ }
+ assert_eq!(s.doc.to_json(), before);
+ assert!(s.render_job_host().is_none());
+ }
+
+ #[test]
+ fn portable_gpu_intent_refuses_cpu_only_optics_instead_of_dropping_them() {
+ let s = Session::default();
+ for (field, value) in [
+ ("punctual_lights", json!([])), ("reflections", json!({})),
+ ("anisotropic_faces", json!([null,null])), ("emissions", json!([])),
+ ] {
+ let mut input = json!({"settings":{"job":{"method":"gpu_path","options":{}}}});
+ input["settings"]["job"]["options"][field] = value;
+ let error = match normalize_saved_job(&s, &input, "render.preflight") {
+ Err(error) => error.to_string(),
+ Ok(_) => panic!("GPU silently accepted CPU-only option {field}"),
+ };
+ assert!(error.contains("unknown field") && error.contains(field), "{error}");
+ }
+ assert!(s.render_job_host().is_none());
+ }
+
+ #[test]
+ fn portable_gpu_volume_conflicts_refuse_before_any_executor_is_available() {
+ let s = Session::default();
+ for options in [
+ json!({"volume":{},"denoise":true}),
+ json!({"volume":{},"transmission_samples":2}),
+ ] {
+ let input = json!({"settings":{"job":{"method":"gpu_path","options":options}}});
+ let error = match normalize_saved_job(&s, &input, "render.preflight") {
+ Err(error) => error.to_string(),
+ Ok(_) => panic!("incompatible volume transport was admitted"),
+ };
+ assert!(error.contains("volume"), "{error}");
+ }
+ assert!(s.render_job_host().is_none());
+ }
+
}
/// Resolve portable intent before choosing an executor. Legacy requests are left intact;
@@ -0,0 +1,275 @@
{
"task": "Minimal integration contract: GPU Point/Spot and multiple arbitrary Directional lights",
"owner": "Worker #2",
"status": "PRIVATE_PROPOSAL_NOT_IMPLEMENTED_UNCOMPILED_UNRUN",
"scope": "Read actual integrated sources and current PRs; define transport and original tests. No checkout/shader edits, renderer/GPU execution, build, publication, or new claim.",
"result": "This is an engine/document/runtime payload and transport gap, not a missing UI control. Current unsupported requests are refused. No silent Point-to-Area, Spot-to-Sun, or hard-Directional-to-finite-Sun conversion is acceptable for the extension.",
"current_source": {
"adapter": {
"file": "crates/engine/src/render_lights.rs",
"anchors": [
"parse_id",
"write",
"inspect",
"filter"
],
"facts": [
"parse_id permits directional:0..7, punctual indices0..7 shared between point and spot, area:0..3.",
"write first caps total analytic lights at20, then directional<=8, combined punctual<=8, area<=4.",
"CPU write preserves hard directional options and PunctualLightOptions including optional SpotConeOptions; nonempty angular radius on CPU directional is refused.",
"GPU write explicitly refuses nonempty punctual or more than one directional before saving. It retains environment and turns rectangles' full sides into half edges.",
"Current GPU mapping of the one Directional with angular_radius_rad=None uses unwrap_or(0.02) and becomes a finite Sun; it multiplies colour by intensity into radiance. This is observable semantic conversion, not preservation of a hard source. ROOT attributes the adapter to Nathan Dane/3cc10aca/#6281/#7265; local blame was reported by ROOT, not rerun here.",
"GPU inspect exposes only the one Sun as Geometry::Directional with Some(radius), and four areas. When no explicit lighting/HDR/sky exists, three implicit fixed hard lights are listed.",
"Visibility is ephemeral and filters a rendering copy. Implicit studio uses three weights; explicit sources are removed from the rendering copy and rewritten. New families must join this same path without saving mute/solo or renumbering a source merely because it is hidden."
]
},
"existing_types": [
{
"type": "schema::render_lights::Geometry::Point",
"fields": [
"position_mm:[f64;3]",
"reference_distance_mm:f64",
"minimum_distance_mm:f64"
],
"existing_saved_representation": "PunctualLightOptions cone=None in CpuJob only"
},
{
"type": "schema::render_lights::Geometry::Spot",
"fields": [
"Point fields",
"emitted_axis:[f64;3] FROM source",
"inner_half_angle_radians",
"outer_half_angle_radians"
],
"existing_saved_representation": "PunctualLightOptions cone=Some(SpotConeOptions) in CpuJob only"
},
{
"type": "schema::render_lights::Geometry::Directional",
"fields": [
"direction:[f64;3] TOWARD source",
"angular_radius_rad:Option<f64>"
],
"existing_saved_representation": "CPU DirectionalLightOptions has no radius; GPU StudioLighting.sun has mandatory finite radius"
},
{
"type": "job::StudioLighting",
"fields": [
"sun:Option<Sun>",
"environment:[f64;3]",
"area_lights:AreaLights<=4"
],
"missing": "No arbitrary hard directional list or punctual list; deny_unknown_fields rejects extensions."
},
{
"type": "doc::GpuJob / job::JobOptions",
"facts": [
"Both carry Option<StudioLighting>; GpuJob.to_runtime and saved GPU normalization carry that exact value.",
"No point/spot or hard-directional values exist to lower; no scalar field is silently dropped from an otherwise accepted existing GPU punctual value.",
"GPU admission rejects CPU raytrace options; passing RaytraceOptions.punctual_lights cannot bypass that contract."
]
}
],
"gpu_payload": {
"file": "crates/gpu/src/path/mod.rs::Task::parameters / path.wgsl::Params",
"parameter_bytes": 1184,
"existing": "One environment;4 rectangular emitters; one normalized toward-Sun axis, cos radius and RGB/density. World positions are recentered relative to camera.target. No punctual position/reference/minimum/cone or arbitrary-directional array/count exists."
},
"transport": {
"files": [
"crates/gpu/src/path/path.wgsl",
"crates/gpu/src/path/shadows.wgsl",
"crates/gpu/src/path/volume.wgsl"
],
"facts": [
"Existing Sun is a uniform finite angular disk with density1/solid_angle and NEE/MIS, and contributes on escaped paths. This is not a delta directional source.",
"Surface direct_studio presently has area/environment/Sun branches; #6850 replaces it plus fixed lights with one source loop and one shadow_transmittance call site.",
"Volume direct already has one loop for area/environment/Sun and a single volume_visible call. New punctual/delta sources require volume integration too, or a clear pre-device refusal when combined with active volume.",
"Visibility carries RGB transmittance, branch PDF, crossings and solid-boundary information. Do not substitute a boolean occlusion check for supported transmission/volume connections."
]
}
},
"proposed_contract": {
"status": "ALL changes in this section are pending design/owner agreement; none exist in the reviewed checkout.",
"types_and_persistence": [
"Extend the shared StudioLighting numerical intent with bounded arbitrary DirectionalLightOptions and PunctualLightOptions lists, reusing SpotConeOptions. Keep existing Sun and areas independent. Exact field names and bounded wrapper types are proposed, not available APIs.",
"Keep JobOptions and StudioLighting Copy. PunctualLightOptions already is Copy; DirectionalLightOptions currently is Clone, not Copy, so using it in a Copy fixed-capacity wrapper requires an explicit small derive/API decision rather than pretending this compiles today.",
"Use bounded decode visitors/fallible construction as existing area/punctual decoders do, not an unbounded Vec followed by truncate. Default empty extension fields and omit them when serialized; absent lighting still means legacy implicit studio, while explicit empty lighting means no direct lights.",
"Adding persistent GPU light intent requires one pending-format descriptor and saved/readback validation with the persistence owner. No released format/history edits. Do not persist schema Light IDs/names as a competing light model.",
"Update actual GpuJob.to_runtime, normalize_saved_job, ProgressiveStart, validation, light write/inspect/filter and command schemas as one coherent change. Saved setups still forbid runtime overrides; raw job:null requests and setup paths must agree. Do not add a second UI authority or edit Nathan's panels/gizmos.",
"Do not silently reinterpret saved finite Sun or its radiance. A hard Directional has no angular spread and no solid-angle scaling. The current None->0.02 conversion must be resolved explicitly with the light author; do not freeze it as desired new hard-light behavior."
],
"units_and_equations": {
"colour": "Linear RGB, exposure/tonemapping only after accumulation. All positions and reference/minimum distances are world mm. Relative intensity is not lumen/lux/watts/IES/CCT calibration.",
"point": "For unshifted shading position x and source p: d=|p-x|, L=(p-x)/d, incident_rgb=colour*I*(reference_mm/d)^2. At d<minimum_mm contribute zero with a diagnostic, not a clamped brightness; d==minimum_mm remains admissible subject to radiance bound.",
"spot": "Normalize emitted_axis FROM source; c=dot(axis,-L). t=clamp((c-cos(outer))/(cos(inner)-cos(outer)),0,1), weight=t*t*(3-2*t). Multiply Point incident by weight. Require0<=inner<outer<=pi/2 and distinct representable cosine bounds. Both direction sign and angle units are contractual.",
"hard_directional": "Normalize direction TOWARD source. Per-source direct contribution is throughput*opaque_fraction*BRDF*NdotL*colour*I*RGB_visibility, with the same geometric-hemisphere rules as the current GPU opaque path. No distance falloff, finite emitter surface, environment-miss radiance, or solid-angle division.",
"delta_estimator": "Deterministically visit each point/spot/hard-directional source once. They are delta sources: no finite solid-angle PDF, no continuous-light MIS power weight and no division by a fake pdf=1. If later selecting one light randomly, include its discrete selection probability explicitly; that is outside this minimal proposal.",
"finite_sun": "Keep existing finite-disk sampling/evaluation/MIS and legacy random-number order for areas/environment/Sun when extension lists are empty. Do not consume additional random numbers for delta lights."
},
"visibility_and_precision": [
"Evaluate distance and cone at unshifted x. Re-aim the shadow segment from the robust geometric origin o toward p; its finite endpoint must exclude the source and everything beyond it. Reject or record an offset that crosses the source instead of sending a backwards ray. Directional uses an unbounded-to-scene connection, not the punctual endpoint.",
"Use the existing area-emitter blocking, shadow_transmittance and volume attenuation semantics in the common source loop. Glass shadow transmission remains a bounded straight connection, not caustic/refraction transport. Do not change existing glass, volume, nesting, denoise or Fresnel-strata exclusions.",
"Preserve the one BVH call site per shared direct source loop required by #6850. Append new source descriptions/selection branches to the agreed loop; do not add one visibility/traversal helper call per family or duplicate sample_path calls.",
"Host-side f64 validation is insufficient for f32 upload: validate finite normalized vectors, camera-relative positions and reference/minimum distances after conversion, and require spot cos(inner)>cos(outer) in the actual uploaded precision. A very narrow valid f64 feather may collapse to identical f32 cosines; refuse clearly before device work rather than widening the cone.",
"Light/geometry precision near very large translations needs an explicit representability rule tied to actual robust geometry/offset policy. A light position that collapses onto a hit after f32 recentering is not permission to increase the minimum distance or silently clamp. Exact precision tolerance/diagnostic code is a pending owner decision."
],
"diagnostics": {
"existing": "job::PunctualDiagnostics already carries near_source, radiance_limit, shadow_offset_crossed; CPU image tracks sample and accumulation failures. CPU max relative intensity64 and max linear radiance1e12 are explicit numerical bounds.",
"gap": "GPU currently has medium-failure atomic readback and ImageWork.step; no implemented punctual diagnostic hook is exposed by GPU Task through that boundary. Reusing a return type by name does not make its transport available.",
"proposal": "Agree a bounded counter/readback/report seam that can carry the existing diagnostic meaning without breaking medium failure layout or poisoning a valid frame with an unrelated error. Reuse PunctualDiagnostics values if practical; exact ImageWork/host seam or added failure words/codes are pending APIs. Silent singular-sample omission is not a parity-complete result.",
"fallback": "If diagnostic transport is deliberately deferred, restrict the accepted GPU domain with a proved preflight rule or refuse that combination. Do not advertise full current CPU singularity semantics."
},
"bounds_and_budget": {
"observed_caps": [
"engine render_lights::write: total20, directional family8, combined point+spot8, rectangle4. parse_id uses8/8/4. These are existing adapter ceilings, not a currently supported GPU envelope.",
"CPU raytrace::lighting::MAX_LIGHTS=8; MAX_LIGHT_INTENSITY=64; MAX_LINEAR_RADIANCE=1e12. job::PunctualLightOptions validates position |component|<=1e9, RGB0..1, I0..64, reference0.001..1e6mm, minimum0.001..reference.",
"Spot axis finite with nonzero component, per-component<=1e9; angles0<=inner<outer<=pi/2 and distinct f64 cosines. CPU directional/radiance validators and GPU Sun/Area validators are different existing domains; do not silently apply the Sun1e8 radiance ceiling to relative punctual intensity.",
"GPU StudioLighting currently only one Sun and MAX_AREA_LIGHTS=4. GPU_PATH_LIMITS: dimension8192, pixels16777216, scratch8GiB; MAX_GPU_PATH_TRIANGLES8000000, MAX_GPU_PATH_BODIES65536, dielectric32; samples1..4096, depth1..16.",
"Dispatch Budget currently64..16384 paths and20ms feedback target, wait30s and setup600s. These are ceilings/controller targets, not a driver-time guarantee."
],
"pending_cap_decision": "Reuse existing adapter caps as ceilings. Whether finite Sun occupies one of the8 directional-family slots (then up to7 extra hard sources when Sun exists) or gets a separately named slot is an explicit pending decision. The former fits existing IDs/total20; allowing8 hard+Sun would exceed the current directional-family8 and total20 at full occupancy and must not be silently introduced.",
"memory": "Keep current GPU and host pool/admission ceilings. Account additional host/upload/device uniform/readback bytes within those ceilings with checked arithmetic. More lights increase per-path visibility work; preserve dispatch cancellation/feedback and require bounded full-cap cases. Do not transfer raster pixel-work budgets or raise thresholds.",
"illustrative_abi_only": {
"status": "Proposed layout, NOT implemented or approved ABI",
"strategy": "Append after existing1184 bytes so current offsets remain stable; update shared Params plus every uploader/denoise/test parameter construction together.",
"counts_bytes": 16,
"directional_slot_bytes": 32,
"directional_slots_reserved": 8,
"punctual_slot_bytes": 64,
"punctual_slots_reserved": 8,
"proposed_total_bytes": 1968,
"directional_fields": "normalized toward axis; colour and relative intensity in two vec4 blocks",
"punctual_fields": "relative position+minimum; colour+I; normalized emitted axis+inner cosine; reference+outer cosine+cone-kind flag+reserved in four vec4 blocks",
"caution": "Validate exact Rust/WGSL alignment/span and bounds. Count types/flags and unused slots must be explicit and zeroed. Fixed reservation8 is a capacity proposal derived from adapter ceilings, not evidence that GPU supports8 today. A different owner-approved payload is equally valid."
}
}
},
"original_test_plan": [
{
"id": "NOW1",
"status": "PRIVATE_WRITTEN_UNCOMPILED_UNRUN",
"target": "crates/engine/src/cmd/render/settings_tests.rs",
"file": "engine-current-refusal-tests.rs",
"purpose": "Current point/spot add and second directional duplicate refuse with the actual unsupported-GPU error; document Arc/revision/history/dirty stay unchanged. No device/host selected. Replace refusal oracle when real support lands."
},
{
"id": "NOW2",
"status": "PRIVATE_WRITTEN_UNCOMPILED_UNRUN_CPU_REFERENCE_ONLY",
"target": "crates/render/src/raytrace/punctual_tests.rs",
"file": "existing-punctual-analytic-tests.rs",
"purpose": "Original3-4-5 spot: cos0.8 halfway between0.9/0.7 -> weight0.5; I4/ref10/d5 -> RGB(8,2,0.5); distance doubled quarters response; reversed axis yields zero. No production oracle copied."
},
{
"id": "NOW3",
"status": "PRIVATE_WRITTEN_UNCOMPILED_UNRUN_CPU_REFERENCE_ONLY",
"target": "crates/render/src/raytrace/punctual_tests.rs",
"file": "existing-punctual-analytic-tests.rs",
"purpose": "Source translated by(1024,-2048,4096) preserves RGB(1.5,6,12); scaling all lengths including reference/minimum preserves response, while doubling only reference multiplies response4. These test existing numerical APIs, not GPU support."
},
{
"id": "GPU1",
"status": "PENDING_PAYLOAD_AND_SHADER_API",
"target": "existing path/tests.rs",
"purpose": "No-device serialization/layout tests for all counts/fields and zeros; saved/raw lowering equality, hostile bounds and ninth/fifth/total overflow refusal before host/device; schema/persistence round-trip. Verify Params span and offsets against actual uploader, not duplicate constants."
},
{
"id": "GPU2",
"status": "PENDING_REAL_TRANSPORT",
"target": "existing path/tests.rs",
"purpose": "Run actual accepted GPU punctual/cone evaluator with NOW2/NOW3 constants and raw linear readback. Add axis reversal, exact inner/outer, cosine-quarter weights0.15625/0.84375, same angle at2d, and f32 collapsed-cosine refusal. No fake new API or ignored green test."
},
{
"id": "GPU3",
"status": "PENDING_REAL_TRANSPORT",
"target": "existing path/tests.rs and shadows coverage",
"purpose": "Point source at(3,4,12), receiver atorigin; a broad triangle atz6 blocks, a triangle atz18 does not. Compare re-aimed finite segment and a source endpoint excluded from blockers; equivalent directional should still see beyond-point geometry. Translate scene/light/camera together. Test geometric offset crossing source and its diagnostic separately."
},
{
"id": "GPU4",
"status": "PENDING_REAL_TRANSPORT",
"target": "existing path/tests.rs",
"purpose": "Two unequal colored hard directionals, non-collinear axes, flat opaque receiver, black explicit environment, one bounce; raw combined signal equals sum of separately evaluated direct contributions. Block only one direction with a small original occluder; the other survives. Reverse direction sign and back-hemisphere cases distinguish normalized TOWARD conventions. Distance translation must not cause attenuation."
},
{
"id": "GPU5",
"status": "PENDING_REAL_TRANSPORT",
"target": "existing path/tests.rs",
"purpose": "Hard-directional contribution stays invariant when no angular parameter exists; Sun disk remains its own radiance/solid-angle/MIS model. No test should certify silently changing a hard light to radius0.02. Legacy no-extension seeded renders keep their previous byte/linear result and RNG sequence."
},
{
"id": "GPU6",
"status": "PENDING_LIFECYCLE_AND_DIAGNOSTIC_SEAMS",
"target": "existing path/tests.rs plus existing host tests",
"purpose": "Singular-near/radiance-limit/offset diagnostics, full-cap counts, cancellation, no publication after invalidation, and admission at exact existing pool limits. Volume Point/Spot/directional phase and finite-distance Beer visibility must be qualified or refused; no silent omission. Medium/glass exclusions remain unchanged."
},
{
"id": "GPU7",
"status": "PENDING_SOURCE_OWNER_AND_RUNTIME_GRANT",
"target": "existing shader/static tests and actual GPU qualification",
"purpose": "Preserve #6850 Naga inlined-BVH-call-graph bound and one sample_path site, #6813 serial compile guard, and exact main source after both merge. Check shader only without a device first; runtime only under ROOT's fresh resource/grant procedure. Additive loop work must not increase shader call-site expansion."
},
{
"id": "UI1",
"status": "PENDING_OWNER_INTEGRATION",
"target": "existing engine/UI light tests",
"purpose": "Existing list/edit/duplicate/remove/mute/solo with durable setup undo/redo/save/reopen; point/spot shared slot IDs and optional Sun slot agreed; hiding never mutates saved rig. Appearance/result/export/gizmo behavior stays with existing owners. Actual screenshots required later for visible support."
}
],
"ownership_and_dependencies": [
{
"pr": 6813,
"owner": "plexi32",
"head": "a7b769c676e1fed71003d57adb1e193e5ce9e55a",
"state": "open at observation",
"rule": "Preserve process-wide path pipeline compile serialization. Not applied here."
},
{
"pr": 6850,
"owner": "plexi32",
"head": "f76bd351d74753674d42f05f17fab2d9aecffa84",
"state": "open at observation",
"rule": "Preserve one direct-light shadow traversal site, one sample_path call, next_vertex compiler fix and Naga BVH-expansion regression. Author says intended setup includes both PRs; ROOT has committed to waiting until both land for runtime qualification."
},
{
"pr": 4016,
"owner": "Karl-VonRichter",
"head": "a556a4938f0bf0d1eb426cf3fef87104b6ae01e1",
"state": "open at observation",
"rule": "Graphics trial/revert/backend decisions untouched. No backend/driver/device-policy change is needed for a light transport contract."
},
{
"pr": 3434,
"owner": "bluecmd",
"head": "7f604e09cc7b9d6b392a0b48bacee03ebbaf4a0c",
"state": "open at observation",
"rule": "Pipeline cache path may overlap GPU constructor/denoiser changes. Do not edit or assume merged; layout/shader changes need fresh exact-source qualification."
},
{
"pr": 8000,
"owner": "Nathan-Dane",
"head": "4ccbb633121628e5d0f3222bd2ab5d1d0e07c6ea",
"state": "open at observation",
"rule": "Panels/editors/gizmos and persistent light editing belong to this owner. Prior agreement frees CPU Interactive domain, not GPU light semantics; ROOT will ask specific light-owner question."
},
{
"pr": 5728,
"owner": "cfaulkingham",
"head": "e1ebd81dab723a02cafba09248ab5805985db5fa",
"state": "open at observation",
"rule": "Preserve GPU-specific admission versus raster projected-work budget. No source from foreign PR is applied."
}
],
"questions_for_engineer_to_take_to_authors": [
"Light/persistence owner: should Directional angular_radius=None become explicit hard delta intent, retaining Some(radius) for the existing finite Sun? What compatibility behavior is intended for editing existing saved finite Suns and the current None-to0.02 adapter? No conversion will be changed without a concrete answer.",
"Light owner: count Sun within the existing8 directional-family slots and20 total, or define another slot/ID? The latter changes existing adapter bounds/IDs and remains pending. Can new bounded lists be added to shared StudioLighting with empty defaults and one pending format entry without touching gizmos?",
"Shader owner: after #6813/#6850 merge, can new delta source branches join the existing one-site surface and volume loops without increasing the current inlined BVH bound? Existing random order for legacy fields remains unchanged.",
"Host/renderer owner: how should existing PunctualDiagnostics cross GPU readback/ImageWork into job status without reusing medium-failure codes incorrectly? What f32 position/cone representability refusal rule is accepted?"
],
"claim_and_licensing": {
"inventory": "github-observed.json records full current664-open-PR search pages and targeted searches. No dedicated Point/Spot/multiple-Directional GPU implementation claim was identified by titles/bodies and targeted terms. Relevant overlapping PR metadata and #6850 shader patch were read. This is a dated census, not reservation or permission; refresh before source implementation.",
"limits": "Only relevant PR changed paths were inspected, not every diff of all664 PRs; an unrelated titled/undocumented change can still overlap. No claim/absence certainty beyond that evidence.",
"licence": "Original analytic values and tests only. Read local clean-room/ground-rules and existing project source; no external lighting implementation, GPL-family code, measured assets/datasets, added dependency, or changed attribution/licence. No third-party media created.",
"authority": "Engineer coordinates author questions and public action; this packet publishes nothing and grants no build/GPU/source-writing authority."
},
"next_action": "Engineer reviews pending light/Sun/count/diagnostic decisions with owners; retain this as a private unimplemented contract. Optional current pure tests can be reviewed and applied only by the integrator, then built by the sole authorized builder. GPU source/runtime remains gated by actual owner integration and fresh authorization."
}
@@ -0,0 +1,29 @@
// PRIVATE UNAPPLIED TEST PROPOSAL. Append inside existing
// crates/engine/src/cmd/render/settings_tests.rs, using its existing
// light_fixture, Session, json and Arc imports. No device/host is opened.
// This guards the CURRENT unsupported contract, not future GPU light support.
// When support lands, replace this refusal oracle with actual lowering coverage.
#[test]
fn gpu_unrepresented_light_families_refuse_without_saving_partial_rig() {
for kind in ["point", "spot", "second_directional"] {
let mut s = Session::default();
light_fixture(&mut s, "gpu_path");
if kind == "second_directional" {
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional"}})).unwrap();
}
s.mark_saved();
let before = s.doc.clone();
let state = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
let params = if kind == "second_directional" {
json!({"name":"Studio","id":"directional:0"})
} else {
json!({"name":"Studio","set":{"kind":kind}})
};
let command = if kind == "second_directional" { "render.lights.duplicate" } else { "render.lights.add" };
let error = s.execute(command, &params).unwrap_err();
assert!(error.to_string().contains("GPU lighting supports one Sun and four Areas"), "{kind}: {error}");
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!(state, (s.revision, s.undo.len(), s.redo.len(), s.is_dirty()));
}
}
@@ -0,0 +1,88 @@
--- a/crates/gpu/src/path/mod.rs
+++ b/crates/gpu/src/path/mod.rs
@@ -100,7 +100,7 @@
let policy = crate::select::Policy::from_env().map_err(gpu)?;
let info = adapter.get_info();
if !viewport_path_backends(info.backend, cfg!(target_os = "windows")).intersects(crate::select::path_backends()) {
- return Err(gpu(format!("{VULKAN_ONLY}; the viewport uses {:?}", info.backend)));
+ return Err(viewport_path_refusal(info.backend, cfg!(target_os = "windows")));
}
if !policy.admits(crate::select::tier_of(info.device_type)) {
return Err(gpu(format!("viewport adapter does not satisfy SOLIDCRAFT_GPU={}", policy.name())));
@@ -119,6 +119,23 @@
// D3D12 shader compilation is avoided; the user can select a CPU image method instead.
const VULKAN_ONLY: &str = "the GPU path tracer requires a Vulkan driver (Metal on macOS); select a CPU image method when none is available";
+
+/// Explain a metadata-only refusal without treating it as a driver survey.
+/// The caller retains the existing backend guard before any device is opened.
+fn viewport_path_refusal(backend: wgpu::Backend, windows: bool) -> JobError {
+ let reason = if windows && !matches!(backend, wgpu::Backend::Vulkan | wgpu::Backend::Metal) {
+ format!(
+ "GPU path tracing is unavailable in this Windows session: the viewport uses {backend:?}, while path tracing requires Vulkan. \
+ This request did not open a Vulkan device and did not test driver availability. Select a CPU image method to continue."
+ )
+ } else {
+ format!(
+ "GPU path tracing is unavailable for the selected graphics backend and configured backend policy; the viewport uses {backend:?}. \
+ Vulkan, or Metal on macOS, is required. Driver availability was not established. Select a CPU image method to continue."
+ )
+ };
+ gpu(reason)
+}
/// Backends a viewport's path device may open, before any driver is loaded.
fn viewport_path_backends(backend: wgpu::Backend, windows: bool) -> wgpu::Backends {
--- a/crates/gpu/src/path/tests.rs
+++ b/crates/gpu/src/path/tests.rs
@@ -556,6 +556,31 @@
let allowed = viewport_path_backends(backend, true);
assert_eq!(allowed, wgpu::Backends::from(backend));
assert_eq!(allowed.contains(wgpu::Backends::VULKAN), backend == wgpu::Backend::Vulkan);
+ }
+}
+
+#[test]
+fn windows_path_refusal_names_the_selected_session_without_testing_a_driver() {
+ for backend in [wgpu::Backend::Dx12, wgpu::Backend::Gl] {
+ let error = viewport_path_refusal(backend, true);
+ assert_eq!(error.code, ErrorCode::HostUnavailable);
+ assert!(error.message.contains("this Windows session"));
+ assert!(error.message.contains(&format!("viewport uses {backend:?}")));
+ assert!(error.message.contains("did not test driver availability"));
+ assert!(error.message.contains("CPU image method"));
+ assert!(!error.message.contains("driver is missing"));
+ }
+}
+
+#[test]
+fn path_policy_refusal_is_not_presented_as_a_driver_survey() {
+ for (backend, windows) in [(wgpu::Backend::Vulkan, true), (wgpu::Backend::Gl, false), (wgpu::Backend::Metal, false)] {
+ let error = viewport_path_refusal(backend, windows);
+ assert_eq!(error.code, ErrorCode::HostUnavailable);
+ assert!(error.message.contains("configured backend policy"));
+ assert!(error.message.contains(&format!("viewport uses {backend:?}")));
+ assert!(error.message.contains("Driver availability was not established"));
+ assert!(error.message.contains("CPU image method"));
}
}
--- a/docs/render-path.md
+++ b/docs/render-path.md
@@ -17,10 +17,12 @@
It opens its independent job device on the viewport's selected adapter,
so software fallback and safe mode do not rediscover a failing hardware driver.
An incompatible explicit GPU policy leaves progressive rendering unavailable there.
-The path tracer runs on Vulkan, or on Metal on macOS (#3217). A viewport on D3D12 is reopened on the
-same GPU through Vulkan. Where there is no Vulkan or Metal driver (Windows over
-Remote Desktop or on the WARP software adapter, older drivers), the path executor reports
-itself unavailable and images render on the CPU. On an RTX 4090, D3D12 was never faster:
+The path tracer runs on Vulkan, or on Metal on macOS (#3217). On Windows it retains the
+viewport's selected backend: a D3D12 or OpenGL session does not open a different Vulkan
+device for path tracing. That refusal does not determine whether a compatible Vulkan
+driver is installed. On other native platforms the same GPU may be reopened through
+the configured Vulkan or Metal backend. An unavailable path executor leaves CPU image
+methods available for explicit selection; it never silently changes a GPU request to CPU. On an RTX 4090, D3D12 was never faster:
equal per sample with DXC and about 55% slower with FXC, and it compiles the path shader
at every start (about 82 s with DXC, about 2 hours with FXC), where Vulkan's compiled
pipelines persist between runs.
@@ -0,0 +1,46 @@
// PRIVATE UNAPPLIED ORIGINAL ANALYTIC TEST PROPOSALS.
// Append inside existing crates/render/src/raytrace/punctual_tests.rs.
// Reuse existing imports from super and solidcraft_geom::Vec3.
// These qualify the existing CPU numerical contract ONLY.
// No GPU payload, shader, pipeline, or new API is invoked.
#[test]
fn gpu_contract_reference_spot_colour_distance_and_cosine_feather_compose() {
let axis = Vec3::new(0.0, 0.0, 7.0);
let cone = SpotCone::new(axis, 0.9_f64.acos(), 0.7_f64.acos()).unwrap();
let colour = Vec3::new(1.0, 0.25, 0.0625);
let light = PunctualLight::new(Vec3::ZERO, colour, 4.0, 10.0, 0.001, Some(cone)).unwrap();
// A 3-4-5 triangle has emitted-axis cosine4/5=0.8.
// Halfway between cone cosines0.9 and0.7 gives smoothstep=1/2.
// I=4, reference10, distance5 => incident=(8,2,0.5).
let a = light.sample(Vec3::new(3.0, 0.0, 4.0)).unwrap();
assert!(a.omitted.is_none());
assert!((a.incident_linear_srgb - Vec3::new(8.0, 2.0, 0.5)).len() < 1e-11);
// Same angle at twice the distance must quarter every colour channel.
let b = light.sample(Vec3::new(6.0, 0.0, 8.0)).unwrap();
assert!(b.omitted.is_none());
assert!((a.incident_linear_srgb - b.incident_linear_srgb * 4.0).len() < 1e-11);
// Reversing only the emitted axis places both samples behind the cone.
let reversed = SpotCone::new(-axis, 0.9_f64.acos(), 0.7_f64.acos()).unwrap();
let dark = PunctualLight::new(Vec3::ZERO, colour, 4.0, 10.0, 0.001, Some(reversed)).unwrap();
assert_eq!(dark.sample(Vec3::new(3.0, 0.0, 4.0)).unwrap().incident_linear_srgb, Vec3::ZERO);
}
#[test]
fn gpu_contract_reference_translation_and_explicit_length_units_preserve_point_response() {
let colour = Vec3::new(0.125, 0.5, 1.0);
let source = Vec3::new(37.0, -19.0, 11.0);
let shift = Vec3::new(1024.0, -2048.0, 4096.0);
let a = PunctualLight::new(source, colour, 3.0, 50.0, 0.001, None).unwrap();
let b = PunctualLight::new(source + shift, colour, 3.0, 50.0, 0.001, None).unwrap();
let c = PunctualLight::new(source, colour, 3.0, 100.0, 0.002, None).unwrap();
let first = a.sample(source + Vec3::Z * 25.0).unwrap();
let translated = b.sample(source + shift + Vec3::Z * 25.0).unwrap();
let scaled = c.sample(source + Vec3::Z * 50.0).unwrap();
assert_eq!(first.incident_linear_srgb, Vec3::new(1.5, 6.0, 12.0));
assert_eq!(first.incident_linear_srgb, translated.incident_linear_srgb);
assert_eq!(first.incident_linear_srgb, scaled.incident_linear_srgb);
// Doubling only the reference length instead quadruples response.
let brighter = c.sample(source + Vec3::Z * 25.0).unwrap();
assert_eq!(brighter.incident_linear_srgb, first.incident_linear_srgb * 4.0);
}
@@ -0,0 +1,98 @@
--- a/crates/render/src/job/image.rs
+++ b/crates/render/src/job/image.rs
@@ -771,10 +771,14 @@
let radiance = crate::studio::shade_weighted(surface, n, view, studio_weights.unwrap_or([1.0; 3])).map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(radiance).map_err(|e| invalid(&e.to_string()))?;
[mapped.x, mapped.y, mapped.z]
- } else if let Some(weights) = studio_weights {
+ } else if let Some(light_weights) = studio_weights {
let [r, g, b] = t.colour;
let surface = crate::surface::OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 1.0, 1.5).map_err(|e| invalid(&e.to_string()))?;
- let radiance = crate::studio::shade_weighted(surface, n, back, weights).map_err(|e| invalid(&e.to_string()))?;
+ let view = eye.map_or(back, |eye| {
+ let world = t.world.iter().zip(weights).fold(Vec3::ZERO, |p, (v, w)| p + *v * (w / sum));
+ eye - world
+ });
+ let radiance = crate::studio::shade_weighted(surface, n, view, light_weights).map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(radiance).map_err(|e| invalid(&e.to_string()))?;
[mapped.x, mapped.y, mapped.z]
} else {
--- a/crates/render/src/job/image_tests.rs
+++ b/crates/render/src/job/image_tests.rs
@@ -202,3 +202,76 @@
// The CPU host itself keeps rejecting the source.
assert_eq!(admit(&source, &r).unwrap_err().code, ErrorCode::LimitExceeded);
}
+
+// Original fixture: a plane at x=40, with exact +X normals. The perspective
+// camera looks diagonally from (50/sqrt(2), -50/sqrt(2), 0). Its actual eye is
+// on the -X side of this plane, although camera.back has a positive X component.
+// The wide field of view keeps the off-axis plane in the image.
+fn weighted_matte_view_scene(explicit: bool) -> ProductScene {
+ let color = [96, 160, 220];
+ let mut part = square("weighted matte plane", color, 1.0, Vec3::ZERO);
+ let mesh = Arc::make_mut(&mut part.mesh);
+ for p in &mut mesh.positions {
+ *p = Vec3::new(40.0, p.x + 10.0, p.z);
+ }
+ mesh.normals = vec![Vec3::X; 4];
+ if explicit {
+ let [r, g, b] = color.map(crate::surface::srgb8_to_linear);
+ part.surface = Some(crate::surface::OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 1.0, 1.5).unwrap());
+ }
+ scene(vec![part])
+}
+
+fn weighted_matte_view_request(perspective: bool) -> ImageRequest {
+ let mut input = request();
+ input.camera.yaw = -std::f64::consts::FRAC_PI_4;
+ input.camera.distance = if perspective { 50.0 } else { 100.0 };
+ input.camera.fov = if perspective { 2.4 } else { 0.0 };
+ input.transparent_background = false;
+ // Deliberately not equal: accidentally using light weights instead of
+ // perspective barycentric weights must not be an equivalent calculation.
+ input.studio_weights = Some([1.0, 0.35, 0.8]);
+ input
+}
+
+#[test]
+fn weighted_scalar_matte_uses_the_same_perspective_view_as_its_explicit_surface() {
+ let input = weighted_matte_view_request(true);
+ assert!(input.camera.back().x > 0.0 && input.camera.eye().x < 40.0);
+ let scalar = finish(ImageTask::new(weighted_matte_view_scene(false), input.clone(), CancelToken::default()).unwrap());
+ let explicit = finish(ImageTask::new(weighted_matte_view_scene(true), input, CancelToken::default()).unwrap());
+ // Same admitted geometry, exact unit-axis normals, colour decoder, matte
+ // response, lights, camera, buffers and encoder. No tolerance or golden.
+ assert_eq!(scalar, explicit);
+ let image = image::load_from_memory(&scalar).unwrap().to_rgba8();
+ assert_eq!(image.get_pixel(0, 0).0, [20, 40, 60, 255]);
+ // Actual view is behind +X everywhere on the plane, so the existing
+ // one-sided BRDF returns zero. This also proves visible product coverage.
+ assert!(image.pixels().any(|pixel| pixel.0 == [0, 0, 0, 255]));
+}
+
+#[test]
+fn weighted_scalar_matte_keeps_exact_orthographic_surface_equivalence() {
+ let input = weighted_matte_view_request(false);
+ let scalar = finish(ImageTask::new(weighted_matte_view_scene(false), input.clone(), CancelToken::default()).unwrap());
+ let explicit = finish(ImageTask::new(weighted_matte_view_scene(true), input, CancelToken::default()).unwrap());
+ assert_eq!(scalar, explicit);
+ let image = image::load_from_memory(&scalar).unwrap().to_rgba8();
+ assert_eq!(image.get_pixel(0, 0).0, [20, 40, 60, 255]);
+ assert!(image.pixels().any(|pixel| pixel.0 != [20, 40, 60, 255] && pixel.0 != [0, 0, 0, 255]));
+}
+
+#[test]
+fn weighted_scalar_matte_mute_still_removes_only_product_light() {
+ let mut input = weighted_matte_view_request(false);
+ let lit = finish(ImageTask::new(weighted_matte_view_scene(false), input.clone(), CancelToken::default()).unwrap());
+ input.studio_weights = Some([0.0; 3]);
+ let muted = finish(ImageTask::new(weighted_matte_view_scene(false), input, CancelToken::default()).unwrap());
+ assert_ne!(lit, muted);
+ for bytes in [&lit, &muted] {
+ let image = image::load_from_memory(bytes).unwrap().to_rgba8();
+ assert_eq!(image.get_pixel(0, 0).0, [20, 40, 60, 255]);
+ }
+ let image = image::load_from_memory(&muted).unwrap().to_rgba8();
+ assert!(image.pixels().any(|pixel| pixel.0 == [0, 0, 0, 255]));
+}
@@ -0,0 +1,225 @@
--- a/crates/render/src/raytrace/planar_shadow_tests.rs
+++ b/crates/render/src/raytrace/planar_shadow_tests.rs
@@ -121,5 +121,222 @@
assert!(plane.intersect(&Ray::new(point, direction, 0.0_f64.next_up(), 8.0).unwrap()).is_none());
}
let oblique = Triangle::new([Vec3::new(-4.0, -4.0, -4.0), Vec3::new(4.0, -4.0, 4.0), Vec3::new(4.0, 4.0, 4.0)]).unwrap();
assert_eq!(oblique.intersect(&Ray::new(Vec3::new(1.0, 1.0, 9.0), -Vec3::Z, 0.0, 10.0).unwrap()).unwrap().distance, 8.0);
}
+
+fn near_coordinate_cases(mut check: impl FnMut(Triangle, Triangle, Ray, usize, f64)) {
+ for axis in 0..3 {
+ for scale in [1e-6, 1.0, 1e6] {
+ for delta in [1e-16, 1e-14, 1e-12] {
+ for translated in [false, true] {
+ let translation = if translated { Vec3::new(1e8, -2e8, 0.0) } else { Vec3::ZERO };
+ let transform = |v| permute(v * scale + translation, axis);
+ let mut vertices = floor();
+ vertices[1].z = delta;
+ let vertices = vertices.map(transform);
+ let far = Triangle::new(
+ [Vec3::new(-1200.0, -1200.0, 1.0), Vec3::new(-1000.0, -1200.0, 1.0), Vec3::new(-1000.0, -1000.0, 1.0)]
+ .map(transform),
+ ).unwrap();
+ for reverse in [false, true] {
+ let [a, b, c] = vertices;
+ let triangle = Triangle::new(if reverse { [a, c, b] } else { [a, b, c] }).unwrap();
+ let (x, y) = (73.17, -21.19);
+ let point = transform(Vec3::new(x, y, delta * (x - y) / 360.0));
+ for direction in [Vec3::new(0.7, -1.0, 0.2), Vec3::new(-1.0, 0.3, 1e-6)] {
+ for sign in [-1.0, 1.0] {
+ let ray = spawned(triangle, point, scale, permute(direction * sign, axis));
+ check(triangle, far, ray, axis, scale);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn planar_cursor_result(bvh: &Bvh, ray: Ray, any: bool) -> Option<Hit> {
+ let cancel = CancelToken::default();
+ let mut work = WorkBudget::new(MAX_WORK).unwrap();
+ let mut cursor = bvh::TraceCursor::new(ray, any);
+ // These fixtures contain at most three triangles. Bound stepping by structure,
+ // not wall-clock time, and fail if the cursor never publishes its answer.
+ for _ in 0..256 {
+ if let Some(result) = cursor.step(bvh, &mut work, &cancel).unwrap() {
+ return result;
+ }
+ }
+ panic!("small planar fixture cursor did not complete");
+}
+
+#[test]
+fn almost_coordinate_planes_leave_both_sides_after_shared_spawn_without_self_hits() {
+ near_coordinate_cases(|triangle, far, ray, _, _| {
+ assert!(far.intersect(&ray).is_none(), "the remote fixture must not occlude this ray");
+ assert!(triangle.intersect(&ray).is_none(), "departed triangle={triangle:?}, ray={ray:?}");
+ });
+}
+
+#[test]
+fn almost_coordinate_depth_survives_mixed_leaves_and_keeps_distinct_close_blockers() {
+ near_coordinate_cases(|triangle, far, ray, axis, scale| {
+ let cancel = CancelToken::default();
+ let height = 0.005 * scale;
+ let center = ray.at(height / component(ray.direction(), axis).abs()).unwrap();
+ let blocker = Triangle::new(
+ [Vec3::new(-180.0, -180.0, 0.0), Vec3::new(180.0, -180.0, 0.0), Vec3::new(0.0, 180.0, 0.0)]
+ .map(|v| permute(v * scale, axis) + center),
+ ).unwrap();
+ assert!(blocker.intersect(&ray).is_some(), "a distinct nearby triangle must remain visible");
+ for leaf_size in [1, 2, 8] {
+ for sources in [vec![triangle, far], vec![far, triangle]] {
+ let bvh = build(sources, leaf_size);
+ assert!(bvh.closest_hit(&ray, &mut WorkBudget::new(MAX_WORK).unwrap(), &cancel).unwrap().is_none());
+ assert!(!bvh.any_hit(&ray, &mut WorkBudget::new(MAX_WORK).unwrap(), &cancel).unwrap());
+ assert!(planar_cursor_result(&bvh, ray, false).is_none());
+ assert!(planar_cursor_result(&bvh, ray, true).is_none());
+ }
+ for (sources, expected) in [(vec![triangle, far, blocker], 2), (vec![blocker, far, triangle], 0)] {
+ let bvh = build(sources, leaf_size);
+ assert_eq!(bvh.closest_hit(&ray, &mut WorkBudget::new(MAX_WORK).unwrap(), &cancel).unwrap().unwrap().primitive_index, expected);
+ assert!(bvh.any_hit(&ray, &mut WorkBudget::new(MAX_WORK).unwrap(), &cancel).unwrap());
+ assert_eq!(planar_cursor_result(&bvh, ray, false).unwrap().primitive_index, expected);
+ assert_eq!(planar_cursor_result(&bvh, ray, true).unwrap().primitive_index, expected);
+ }
+ }
+ });
+}
+
+#[test]
+fn oblique_plane_depth_preserves_closed_edges_intervals_grazing_and_closest_ties() {
+ for axis in 0..3 {
+ let unpermute = |v: Vec3| match axis {
+ 0 => Vec3::new(v.y, v.z, v.x),
+ 1 => Vec3::new(v.z, v.x, v.y),
+ _ => v,
+ };
+ for translation in [Vec3::ZERO, Vec3::new(100.0, -200.0, 300.0)] {
+ let transform = |v| permute(v + translation, axis);
+ let [a, b, c, d] = [Vec3::new(-4.0, -4.0, -4.0), Vec3::new(4.0, -4.0, 4.0), Vec3::new(4.0, 4.0, 4.0), Vec3::new(-4.0, 4.0, -4.0)]
+ .map(transform);
+ for reverse in [false, true] {
+ let first = Triangle::new(if reverse { [a, c, b] } else { [a, b, c] }).unwrap();
+ let second = Triangle::new(if reverse { [a, d, c] } else { [a, c, d] }).unwrap();
+ for point in [Vec3::new(1.0, 1.0, 1.0), Vec3::new(4.0, 4.0, 4.0)] {
+ let origin = transform(point + Vec3::Z * 8.0);
+ let direction = permute(-Vec3::Z, axis);
+ let ray = Ray::new(origin, direction, 8.0, 8.0).unwrap();
+ assert_eq!(first.intersect(&ray).unwrap().distance, 8.0);
+ assert_eq!(second.intersect(&ray).unwrap().distance, 8.0);
+ assert!(first.intersect(&Ray::new(origin, direction, 8.0_f64.next_up(), 12.0).unwrap()).is_none());
+ assert!(first.intersect(&Ray::new(origin, direction, 0.0, 8.0_f64.next_down()).unwrap()).is_none());
+ for leaf_size in [1, 2, 8] {
+ for sources in [vec![first, second], vec![second, first], vec![first, first]] {
+ let bvh = build(sources, leaf_size);
+ let hit = bvh.closest_hit(&ray, &mut WorkBudget::new(MAX_WORK).unwrap(), &CancelToken::default()).unwrap().unwrap();
+ assert_eq!(hit.primitive_index, 0);
+ assert_eq!(planar_cursor_result(&bvh, ray, false).unwrap().primitive_index, 0);
+ }
+ }
+ }
+ // Independent analytic plane z=x. Use the actual normalized ray,
+ // not the production plane normal, for the expected distance.
+ for tilt in [1.0 / 1024.0, 1.0 / 1_073_741_824.0] {
+ let direction = Vec3::new(1.0, 0.0, 1.0 + tilt);
+ let ray = Ray::new(transform(Vec3::new(1.0, 0.0, 1.0) - direction * 4.0), permute(direction, axis), 0.0, 20.0).unwrap();
+ let origin = unpermute(ray.origin()) - translation;
+ let direction = unpermute(ray.direction());
+ let expected = (origin.x - origin.z) / (direction.z - direction.x);
+ assert!(expected.is_finite() && expected > 0.0);
+ assert_eq!(first.intersect(&ray).unwrap().distance, expected);
+ }
+ for zero in [0.0, -0.0] {
+ let ray = Ray::new(transform(Vec3::new(1.0, 0.0, 1.0)), permute(Vec3::new(1.0, zero, 1.0), axis), 0.0, 20.0).unwrap();
+ assert!(first.intersect(&ray).is_none(), "a ray in the oblique plane remains a miss");
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn nonconstant_dyadic_plane_keeps_its_tiny_positive_closed_depth() {
+ let length = (1_u64 << 39) as f64;
+ let elevation = f64::from_bits(1_u64 << 34); // 2^-1040
+ let expected = f64::from_bits(3_u64 << 33); // 3 * 2^-1041
+ for axis in 0..3 {
+ let vertices = [Vec3::ZERO, Vec3::new(length, 0.0, elevation), Vec3::new(0.0, length, elevation)].map(|v| permute(v, axis));
+ for reverse in [false, true] {
+ let [a, b, c] = vertices;
+ let triangle = Triangle::new(if reverse { [a, c, b] } else { [a, b, c] }).unwrap();
+ let origin = permute(Vec3::new(length / 4.0, length / 4.0, -elevation), axis);
+ let direction = permute(Vec3::Z, axis);
+ // The vertex plane is z=e*(x+y)/L. At this interior point it is
+ // e/2, hence the exact forward depth from -e is 3e/2.
+ let closed = Ray::new(origin, direction, expected, expected).unwrap();
+ assert_eq!(triangle.intersect(&closed).unwrap().distance, expected);
+ let broad = Ray::new(origin, direction, 0.0, elevation * 4.0).unwrap();
+ assert_eq!(triangle.intersect(&broad).unwrap().distance, expected);
+ assert!(triangle.intersect(&Ray::new(origin, direction, expected.next_up(), elevation * 4.0).unwrap()).is_none());
+ assert!(triangle.intersect(&Ray::new(origin, direction, 0.0, expected.next_down()).unwrap()).is_none());
+ }
+ }
+}
+
+fn dyadic_cursor_result(bvh: &Bvh, ray: Ray, any: bool) -> Option<Hit> {
+ let cancel = CancelToken::default();
+ let mut work = WorkBudget::new(MAX_WORK).unwrap();
+ let mut cursor = bvh::TraceCursor::new(ray, any);
+ for _ in 0..128 {
+ if let Some(result) = cursor.step(bvh, &mut work, &cancel).unwrap() {
+ return result;
+ }
+ }
+ panic!("two-triangle dyadic cursor did not complete");
+}
+
+#[test]
+fn a_distinct_near_oblique_blocker_keeps_its_analytic_depth_after_spawn() {
+ let length = (1_u64 << 39) as f64;
+ let elevation = f64::from_bits(1_u64 << 34); // 2^-1040
+ let expected = f64::from_bits(3_u64 << 33); // 3 * 2^-1041
+ for axis in 0..3 {
+ let source = [Vec3::ZERO, Vec3::new(length, 0.0, 0.0), Vec3::new(0.0, length, 0.0)].map(|v| permute(v, axis));
+ for reverse in [false, true] {
+ let [a, b, c] = source;
+ let departed = Triangle::new(if reverse { [a, c, b] } else { [a, b, c] }).unwrap();
+ let point = permute(Vec3::new(length / 4.0, length / 4.0, 0.0), axis);
+ let spawned = spawned(departed, point, 1.0, permute(Vec3::Z, axis));
+ let origin_height = component(spawned.origin(), axis);
+ // Anchor the independent oblique blocker above the actual spawned
+ // ray. Its three heights differ; no exact-coordinate-plane shortcut
+ // applies. At x=y=L/4 its height above the origin is exactly3e/2.
+ let blocker = [
+ Vec3::new(0.0, 0.0, origin_height + elevation),
+ Vec3::new(length, 0.0, origin_height + elevation * 2.0),
+ Vec3::new(0.0, length, origin_height + elevation * 2.0),
+ ].map(|v| permute(v, axis));
+ let [a, b, c] = blocker;
+ let blocker = Triangle::new(if reverse { [a, c, b] } else { [a, b, c] }).unwrap();
+ let ray = Ray::new(spawned.origin(), spawned.direction(), expected, expected).unwrap();
+ assert!(departed.intersect(&ray).is_none());
+ assert_eq!(blocker.intersect(&ray).unwrap().distance, expected);
+ let cancel = CancelToken::default();
+ for leaf_size in [1, 2, 8] {
+ for (sources, index) in [(vec![departed, blocker], 1), (vec![blocker, departed], 0)] {
+ let bvh = build(sources, leaf_size);
+ let hit = bvh.closest_hit(&ray, &mut WorkBudget::new(MAX_WORK).unwrap(), &cancel).unwrap().unwrap();
+ assert_eq!(hit.primitive_index, index);
+ assert_eq!(hit.geometry.distance, expected);
+ assert!(bvh.any_hit(&ray, &mut WorkBudget::new(MAX_WORK).unwrap(), &cancel).unwrap());
+ for any in [false, true] {
+ let hit = dyadic_cursor_result(&bvh, ray, any).unwrap();
+ assert_eq!(hit.primitive_index, index);
+ assert_eq!(hit.geometry.distance, expected);
+ }
+ }
+ }
+ }
+ }
+}
@@ -0,0 +1,3 @@
# Retired production candidate
DO NOT APPLY plane-depth-candidate.patch. Independent review found an admitted subnormal/dyadic case where it introduces a miss. No replacement production fix is accepted. Only the separate baseline test proposal may advance, after real execution. No epsilon, tolerance, assertion or golden change is authorized.
@@ -0,0 +1,52 @@
--- a/crates/render/src/raytrace/mod.rs
+++ b/crates/render/src/raytrace/mod.rs
@@ -269,9 +269,9 @@
let projected = self.vertices.map(|v| {
let delta = v - ray.origin;
let z = component(delta, kz);
- [component(delta, kx) - sx * z, component(delta, ky) - sy * z, z / dominant]
+ [component(delta, kx) - sx * z, component(delta, ky) - sy * z]
});
- let [[ax, ay, az], [bx, by, bz], [cx, cy, cz]] = projected;
+ let [[ax, ay], [bx, by], [cx, cy]] = projected;
let weights = [bx * cy - by * cx, cx * ay - cy * ax, ax * by - ay * bx];
let [u, v, w] = weights;
if (u < 0.0 || v < 0.0 || w < 0.0) && (u > 0.0 || v > 0.0 || w > 0.0) {
@@ -282,9 +282,6 @@
return None;
}
let barycentric = weights.map(|value| value / determinant);
- let [u, v, w] = barycentric;
- // Divide before multiplying by depth: very small triangle areas need not
- // underflow their otherwise well-represented hit distance.
// An exact coordinate plane has a direct depth equation. Summing
// depths along another dominant ray axis can cancel large tangential
// terms into a false positive. No interval or edge policy changes.
@@ -295,7 +292,26 @@
let distance = if let Some((axis, coordinate)) = axis_plane {
(coordinate - component(ray.origin, axis)) / component(d, axis)
} else {
- u * az + v * bz + w * cz
+ // Evaluate the plane directly rather than cancelling tangential
+ // depths from the shear. Scale by the largest normal component:
+ // its coefficient is exactly one, without a normalisation factor
+ // multiplying nearly equal terms in a grazing-ray denominator.
+ let kn = if normal.x.abs() >= normal.y.abs() && normal.x.abs() >= normal.z.abs() {
+ 0
+ } else if normal.y.abs() >= normal.z.abs() {
+ 1
+ } else {
+ 2
+ };
+ let ka = (kn + 1) % 3;
+ let kb = (ka + 1) % 3;
+ let major = component(normal, kn);
+ let a = component(normal, ka) / major;
+ let b = component(normal, kb) / major;
+ let delta = self.vertices[0] - ray.origin;
+ let numerator = component(delta, kn) + a * component(delta, ka) + b * component(delta, kb);
+ let denominator = component(d, kn) + a * component(d, ka) + b * component(d, kb);
+ numerator / denominator
};
if !distance.is_finite() || distance < ray.min || distance > ray.max {
return None;
@@ -0,0 +1,164 @@
{
"en": {
"cmd.render.interactive": "Interactive Render",
"help.render.interactive": "Explicitly enable a provisional CPU preview while editing. Final render settings and exportable images stay unchanged.",
"render.interactive_error": "Interactive preview error",
"render.interactive_final_active": "Final render active",
"render.interactive_off": "Off",
"render.interactive_on": "On",
"render.interactive_pause": "Pause",
"render.interactive_paused": "Paused",
"render.interactive_provisional": "Provisional preview",
"render.interactive_refining": "Refining preview",
"render.interactive_requires_cpu": "Interactive preview requires CPU ray tracing.",
"render.interactive_resume": "Resume",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Out of date",
"render.interactive_target": "Sample target reached",
"render.interactive_updating": "Updating"
},
"ar": {
"cmd.render.interactive": "تصيير تفاعلي",
"help.render.interactive": "فعّل معاينة مؤقتة باستخدام المعالج CPU أثناء التحرير. تبقى إعدادات التصيير النهائي والصور القابلة للتصدير دون تغيير.",
"render.interactive_error": "خطأ في المعاينة التفاعلية",
"render.interactive_final_active": "التصيير النهائي قيد التنفيذ",
"render.interactive_off": "إيقاف",
"render.interactive_on": "تفعيل",
"render.interactive_pause": "إيقاف مؤقت",
"render.interactive_paused": "متوقف مؤقتًا",
"render.interactive_provisional": "معاينة مؤقتة",
"render.interactive_refining": "جارٍ تحسين المعاينة",
"render.interactive_requires_cpu": "تتطلب المعاينة التفاعلية تتبّع الأشعة على المعالج CPU.",
"render.interactive_resume": "استئناف",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "غير محدثة",
"render.interactive_target": "تم بلوغ عدد العينات المطلوب",
"render.interactive_updating": "جارٍ التحديث"
},
"de": {
"cmd.render.interactive": "Interaktives Rendering",
"help.render.interactive": "Aktivieren Sie die vorläufige CPU-Vorschau zum Bearbeiten manuell. Einstellungen für das finale Rendering und exportierbare Bilder bleiben unverändert.",
"render.interactive_error": "Fehler in der interaktiven Vorschau",
"render.interactive_final_active": "Finales Rendering aktiv",
"render.interactive_off": "Aus",
"render.interactive_on": "Ein",
"render.interactive_pause": "Pausieren",
"render.interactive_paused": "Pausiert",
"render.interactive_provisional": "Vorläufige Vorschau",
"render.interactive_refining": "Vorschau wird verfeinert",
"render.interactive_requires_cpu": "Die interaktive Vorschau erfordert CPU-Raytracing.",
"render.interactive_resume": "Fortsetzen",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Nicht aktuell",
"render.interactive_target": "Zielanzahl an Samples erreicht",
"render.interactive_updating": "Wird aktualisiert"
},
"es": {
"cmd.render.interactive": "Render interactivo",
"help.render.interactive": "Activa explícitamente una vista previa provisional por CPU mientras editas. Los ajustes del render final y las imágenes exportables se mantienen intactos.",
"render.interactive_error": "Error en la vista previa interactiva",
"render.interactive_final_active": "Render final activo",
"render.interactive_off": "Desactivar",
"render.interactive_on": "Activar",
"render.interactive_pause": "Pausar",
"render.interactive_paused": "En pausa",
"render.interactive_provisional": "Vista previa provisional",
"render.interactive_refining": "Refinando vista previa",
"render.interactive_requires_cpu": "La vista previa interactiva requiere trazado de rayos por CPU.",
"render.interactive_resume": "Reanudar",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Desactualizada",
"render.interactive_target": "Número objetivo de muestras alcanzado",
"render.interactive_updating": "Actualizando"
},
"he": {
"cmd.render.interactive": "רינדור אינטראקטיבי",
"help.render.interactive": "יש להפעיל במפורש תצוגה מקדימה זמנית במעבד CPU בזמן העריכה. הגדרות הרינדור הסופי והתמונות הזמינות לייצוא נשארות ללא שינוי.",
"render.interactive_error": "שגיאה בתצוגה המקדימה האינטראקטיבית",
"render.interactive_final_active": "הרינדור הסופי פעיל",
"render.interactive_off": "כיבוי",
"render.interactive_on": "הפעלה",
"render.interactive_pause": "השהיה",
"render.interactive_paused": "מושהה",
"render.interactive_provisional": "תצוגה מקדימה זמנית",
"render.interactive_refining": "משפר את התצוגה המקדימה",
"render.interactive_requires_cpu": "תצוגה מקדימה אינטראקטיבית דורשת עקיבת קרניים במעבד CPU.",
"render.interactive_resume": "המשך",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "לא עדכני",
"render.interactive_target": "הושג מספר הדגימות המבוקש",
"render.interactive_updating": "מעדכן"
},
"ja": {
"cmd.render.interactive": "インタラクティブレンダリング",
"help.render.interactive": "編集中にCPUによる暫定プレビューを明示的に有効にします。最終レンダリングの設定とエクスポート可能な画像は変更しません。",
"render.interactive_error": "インタラクティブプレビューのエラー",
"render.interactive_final_active": "最終レンダリング中",
"render.interactive_off": "無効にする",
"render.interactive_on": "有効にする",
"render.interactive_pause": "一時停止",
"render.interactive_paused": "一時停止中",
"render.interactive_provisional": "暫定プレビュー",
"render.interactive_refining": "プレビューを改善中",
"render.interactive_requires_cpu": "インタラクティブプレビューにはCPUレイトレーシングが必要です。",
"render.interactive_resume": "再開",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "最新ではありません",
"render.interactive_target": "目標サンプル数に到達",
"render.interactive_updating": "更新中"
},
"ko": {
"cmd.render.interactive": "대화형 렌더링",
"help.render.interactive": "편집하는 동안 임시 CPU 미리 보기를 직접 켭니다. 최종 렌더링 설정과 내보낼 수 있는 이미지는 변경하지 않습니다.",
"render.interactive_error": "대화형 미리 보기 오류",
"render.interactive_final_active": "최종 렌더링 진행 중",
"render.interactive_off": "끄기",
"render.interactive_on": "켜기",
"render.interactive_pause": "일시 중지",
"render.interactive_paused": "일시 중지됨",
"render.interactive_provisional": "임시 미리 보기",
"render.interactive_refining": "미리 보기 개선 중",
"render.interactive_requires_cpu": "대화형 미리 보기에는 CPU 레이 트레이싱이 필요합니다.",
"render.interactive_resume": "계속",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "최신 상태가 아님",
"render.interactive_target": "목표 샘플 수 도달",
"render.interactive_updating": "업데이트 중"
},
"vi": {
"cmd.render.interactive": "Kết xuất tương tác",
"help.render.interactive": "Chủ động bật bản xem trước tạm thời bằng CPU khi chỉnh sửa. Thiết lập kết xuất cuối và các ảnh có thể xuất vẫn được giữ nguyên.",
"render.interactive_error": "Lỗi xem trước tương tác",
"render.interactive_final_active": "Đang kết xuất ảnh cuối",
"render.interactive_off": "Tắt",
"render.interactive_on": "Bật",
"render.interactive_pause": "Tạm dừng",
"render.interactive_paused": "Đã tạm dừng",
"render.interactive_provisional": "Bản xem trước tạm thời",
"render.interactive_refining": "Đang cải thiện bản xem trước",
"render.interactive_requires_cpu": "Xem trước tương tác yêu cầu dò tia bằng CPU.",
"render.interactive_resume": "Tiếp tục",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Chưa cập nhật",
"render.interactive_target": "Đã đạt số mẫu mục tiêu",
"render.interactive_updating": "Đang cập nhật"
},
"zh": {
"cmd.render.interactive": "交互式渲染",
"help.render.interactive": "编辑时手动启用临时 CPU 预览。最终渲染设置和可导出的图像保持不变。",
"render.interactive_error": "交互式预览错误",
"render.interactive_final_active": "最终渲染进行中",
"render.interactive_off": "关闭",
"render.interactive_on": "启用",
"render.interactive_pause": "暂停",
"render.interactive_paused": "已暂停",
"render.interactive_provisional": "临时预览",
"render.interactive_refining": "正在细化预览",
"render.interactive_requires_cpu": "交互式预览需要 CPU 光线追踪。",
"render.interactive_resume": "继续",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "不是最新状态",
"render.interactive_target": "已达到目标采样数",
"render.interactive_updating": "正在更新"
}
}
@@ -0,0 +1,404 @@
//! The command registry. Ids are SolidCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout.
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
macro_rules! created_doc {
() => {
". Returns created: {points, curves, constraints, dimensions}: the ids of what it added (stable and never reused: usable right away in sketch.dimension, sketch.constraint.*, sketch.delete…)"
};
}
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
macro_rules! planned {
($id:literal, $label:literal, $issue:literal) => {
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
.planned($issue)
};
}
mod bom;
mod browser;
pub(crate) mod clearance;
mod clipboard;
mod compare;
mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod delete;
mod diagnostics;
mod edit;
mod face;
mod features;
mod features_more;
mod join_pieces;
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, axis_dir_form, edge_ref_form, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub use join_pieces::{Addition, addition};
pub use sketch::dimension_roles;
pub use sketch_import::drawing_import_command;
pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
pub use view_dxf::sketch::exportable as view_dxf_exportable;
pub use view_navigation::standard_view;
mod component_link;
pub use component_link::resolve_design_links;
mod config;
mod design_link;
mod file;
mod frame;
pub(crate) mod inspect;
mod interference;
pub(crate) mod joints;
mod libraries;
mod material_properties;
mod materials;
mod measure_sel;
mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod parts;
mod point_cloud;
mod script;
mod section;
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
#[cfg(test)]
mod kernel_heal_tests;
mod parameter_sweep;
mod parameters;
mod plastic;
mod render;
mod render_ui;
#[cfg(test)]
mod script_tests;
mod sheet;
pub(crate) use sheet::{
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
then_list,
};
mod silhouette;
mod sketch;
pub(crate) use sketch::author_path_for_rebind;
pub(crate) use sketch::created as sketch_created;
mod canvas_sketch;
mod sketch_3d;
mod sketch_canvas;
pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod sketch_store;
mod surface;
mod surface_analysis;
mod surface_boundary;
mod surface_match;
mod view_dxf;
mod view_navigation;
use serde::Serialize;
use serde_json::Value;
use crate::{Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
/// A command's form for one frame of an interactive gesture: its result and the sketch it
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
/// evaluate as usual).
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub type SchemaFn = fn() -> schemars::Schema;
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Toolbar tab (`SOLID`, `SKETCH`…); empty = not on the toolbar.
pub tab: &'static str,
/// Toolbar panel (`CREATE`, `MODIFY`…).
pub panel: &'static str,
/// Icon name for the UI (drawn in code).
pub icon: &'static str,
pub shortcut: Option<&'static str>,
/// One-line parameter documentation.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
pub undoable: bool,
/// What runs instead of `run` when the command is given `live: true` (a frame of an
/// interactive gesture, see [`Session::execute`]).
pub live: Option<LiveRun>,
/// Reads the evaluated model (the default). A command that reads only the document and the
/// session's history runs on a model left stale by a live gesture without evaluating it first.
pub reads_model: bool,
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
pub planned: Option<&'static str>,
/// The command's typed parameter contract, which carries its input schema; `None` until
/// the command is converted (see `crate::schema`).
pub contract: Option<&'static dyn crate::schema::Contract>,
/// The JSON Schema of a successful result; `None` until described.
pub output: Option<SchemaFn>,
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
CommandSpec {
id,
label,
tab: "",
panel: "",
icon: "",
shortcut: None,
params: "",
enabled: always,
run,
undoable: true,
live: None,
reads_model: true,
planned: None,
contract: None,
output: None,
}
}
/// Not built yet; `issue` tracks building it.
pub const fn planned(mut self, issue: &'static str) -> Self {
self.planned = Some(issue);
self
}
/// Takes `live: true`, run by `run`.
pub const fn live(mut self, run: LiveRun) -> Self {
self.live = Some(run);
self
}
/// Reads only the document (and history), never the evaluated model.
pub const fn doc_only(mut self) -> Self {
self.reads_model = false;
self
}
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
self.tab = tab;
self.panel = panel;
self
}
pub const fn icon(mut self, i: &'static str) -> Self {
self.icon = i;
self
}
pub const fn key(mut self, k: &'static str) -> Self {
self.shortcut = Some(k);
self
}
/// The command's typed parameter contract (see `crate::schema`).
pub const fn contract(mut self, c: &'static dyn crate::schema::Contract) -> Self {
self.contract = Some(c);
self
}
/// The JSON Schema of a successful result.
pub const fn output(mut self, f: SchemaFn) -> Self {
self.output = Some(f);
self
}
pub const fn params(mut self, p: &'static str) -> Self {
self.params = p;
self
}
pub const fn enabled(mut self, e: Enabled) -> Self {
self.enabled = e;
self
}
pub const fn noundo(mut self) -> Self {
self.undoable = false;
self
}
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
/// undo step and the evaluation).
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
let r = (self.run)(s, p);
// A parameter error that did not name the command names it here (see `Session::execute`).
match r {
Err(crate::EngineError::BadParams { cmd, msg }) if cmd.is_empty() => Err(crate::EngineError::BadParams { cmd: self.id.to_string(), msg }),
other => other,
}
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let (label, icon) = match self.id {
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
_ => (self.label, self.icon),
};
CommandInfo {
id: self.id,
label,
tab: self.tab,
panel: self.panel,
icon,
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
planned: self.planned,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub tab: &'static str,
pub panel: &'static str,
pub icon: &'static str,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
pub disabled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<&'static str>,
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
}
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
/// geometry under the cursor while a sketch is open.
pub fn not_in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_none() { Ok(()) } else { Err("a sketch is being edited (this works on bodies: Finish Sketch first)".into()) }
}
/// Every command, in toolbar order.
pub fn command_specs() -> Vec<&'static CommandSpec> {
let mut v: Vec<&'static CommandSpec> = Vec::new();
v.extend(sketch::COMMANDS.iter());
v.extend(sketch_create::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
v.extend(sketch_ends::COMMANDS.iter());
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(canvas_sketch::COMMANDS.iter());
v.extend(point_cloud::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface_boundary::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(surface_match::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(frame::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(bom::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(sketch_clipboard::COMMANDS.iter());
v.extend(script::COMMANDS.iter());
v.extend(parameters::COMMANDS.iter());
v.extend(parameter_sweep::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(metadata::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(compare::COMMANDS.iter());
v.extend(silhouette::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v.extend(render_ui::COMMANDS.iter());
v
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
/// (`file.open` → `doc.open`). Empty when nothing is close.
pub(crate) fn did_you_mean(id: &str) -> String {
let id = id.to_ascii_lowercase();
if id.len() > 64 {
return String::new();
}
let last = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
let near = (id.chars().count() / 4).max(2);
let mut found: Vec<(bool, usize, &str)> = command_specs()
.iter()
.filter_map(|c| {
let d = edit_distance(&id, c.id);
(d <= near || last(&id) == last(c.id)).then_some((d > near, d, c.id))
})
.collect();
found.sort();
let ids: Vec<String> = found.iter().take(3).map(|(_, _, c)| format!("`{c}`")).collect();
match ids.as_slice() {
[] => String::new(),
[one] => format!(" (did you mean {one}?)"),
[rest @ .., end] => format!(" (did you mean {} or {end}?)", rest.join(", ")),
}
}
/// Levenshtein distance: the single-character edits that turn `a` into `b`.
pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut prev = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let next = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(row[j + 1] + 1);
prev = row[j + 1];
row[j + 1] = next;
}
}
row[b.len()]
}
pub(super) fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<solidcraft_sketch::PatternAlignment> {
p.get(key).map_or(Ok(solidcraft_sketch::PatternAlignment::default()), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be left, center or right")))
})
}
pub(super) fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result<bool> {
p.get(key).map_or(Ok(false), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be true or false")))
})
}
pub(crate) fn prepare_interactive(s: &crate::Session, params: &serde_json::Value) -> crate::Result<crate::render_interactive::Prepared> {
render::prepare_interactive(s, params)
}
@@ -0,0 +1,424 @@
//! Render capability discovery and persistent intent. No rendering or host probing.
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Value, json};
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
use super::CommandSpec;
use crate::params::bad;
use crate::{Result, Session};
mod clear_coat;
mod effects;
mod environment;
mod jobs;
mod library;
mod library_data;
#[cfg(test)]
mod light_union_tests;
mod lights;
mod optical_bindings;
mod physical_library;
mod resources;
mod shots;
mod staging;
mod studio_presets;
mod surface_bindings;
mod textures;
mod transmission;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("render.environment.set", "Set Render Environment", crate::checked!("render.environment.set", environment::set)).icon("settings").doc_only()
.contract(&environment::CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render::EnvironmentResult>)
.params("name; set?: numerical setup patch; rotation_deg?: finite degrees within +/-1000000, wrapped to [-180,180); intensity?: finite linear intensity 0..64 CPU or 0..100 GPU. Edits the existing HDR, sky or constant environment in one undo, without selecting a source, moving the camera or starting a render. Absent environments and raster refuse."),
CommandSpec::new("render.lights.set", "Set Render Light", crate::checked!("render.lights.set", lights::set))
.icon("render_scene").doc_only()
.contract(&crate::schema::TypedContract::<crate::schema::render_lights::EditParams>::new())
.output(crate::schema::output_schema_for::<crate::schema::render_lights::EditResult>)
.params("name; id except add; settings?: full unsaved RenderSetup; expected_revision?:u64; set for set/add: kind directional/point/spot/area, intensity_ev, temperature_k, softness, azimuth_deg, elevation_deg, distance_mm, size_mm, aim_at, width_mm, height_mm, position_mm, direction, edge_u_mm, edge_v_mm, radiance, colour, reference_distance_mm, minimum_distance_mm. Validated finite light edit in one undo. Existing family caps and backend support apply; refusal is atomic. Names remain defaults."),
CommandSpec::new("render.lights.add", "Add Light", crate::checked!("render.lights.add", lights::add))
.icon("render_scene").doc_only()
.contract(&crate::schema::TypedContract::<crate::schema::render_lights::EditParams>::new())
.output(crate::schema::output_schema_for::<crate::schema::render_lights::EditResult>)
.params("name; id except add; settings?: full unsaved RenderSetup; expected_revision?:u64; set for set/add: kind directional/point/spot/area, intensity_ev, temperature_k, softness, azimuth_deg, elevation_deg, distance_mm, size_mm, aim_at, width_mm, height_mm, position_mm, direction, edge_u_mm, edge_v_mm, radiance, colour, reference_distance_mm, minimum_distance_mm. Validated finite light edit in one undo. Existing family caps and backend support apply; refusal is atomic. Names remain defaults."),
CommandSpec::new("render.lights.duplicate", "Duplicate Render Light", crate::checked!("render.lights.duplicate", lights::duplicate))
.icon("render_scene").doc_only()
.contract(&crate::schema::TypedContract::<crate::schema::render_lights::EditParams>::new())
.output(crate::schema::output_schema_for::<crate::schema::render_lights::EditResult>)
.params("name; id except add; settings?: full unsaved RenderSetup; expected_revision?:u64; set for set/add: kind directional/point/spot/area, intensity_ev, temperature_k, softness, azimuth_deg, elevation_deg, distance_mm, size_mm, aim_at, width_mm, height_mm, position_mm, direction, edge_u_mm, edge_v_mm, radiance, colour, reference_distance_mm, minimum_distance_mm. Validated finite light edit in one undo. Existing family caps and backend support apply; refusal is atomic. Names remain defaults."),
CommandSpec::new("render.lights.remove", "Remove Render Light", crate::checked!("render.lights.remove", lights::remove))
.icon("render_scene").doc_only()
.contract(&crate::schema::TypedContract::<crate::schema::render_lights::EditParams>::new())
.output(crate::schema::output_schema_for::<crate::schema::render_lights::EditResult>)
.params("name; id except add; settings?: full unsaved RenderSetup; expected_revision?:u64; set for set/add: kind directional/point/spot/area, intensity_ev, temperature_k, softness, azimuth_deg, elevation_deg, distance_mm, size_mm, aim_at, width_mm, height_mm, position_mm, direction, edge_u_mm, edge_v_mm, radiance, colour, reference_distance_mm, minimum_distance_mm. Validated finite light edit in one undo. Existing family caps and backend support apply; refusal is atomic. Names remain defaults."),
CommandSpec::new("render.lights.list", "List Render Lights", crate::checked!("render.lights.list", lights::list))
.icon("list").noundo().doc_only()
.contract(&crate::schema::TypedContract::<crate::schema::render_lights::ListParams>::new())
.output(crate::schema::output_schema_for::<crate::schema::render_lights::ListResult>)
.params("setup?: saved setup name OR settings?: complete RenderSetup snapshot; neither uses defaults. Returns {lights:[{id,kind,name,name_key,number,implicit,intensity,colour,...spatial fields}]}. IDs are kind:zero-based-source-slot, stable across numerical edits but not family changes/reordering/removal/rig replacement; point and spot share punctual slots. Includes implicit default/preset lights. GPU RGB radiance is factored as colour * intensity; area edges are full world-mm vectors. Read-only, no host, camera change, save, render, model evaluation or undo. See docs/render-settings.md."),
CommandSpec::new("render.aging.describe","Layered Surface Aging",effects::aging).icon("appearance").noundo().doc_only()
.params("Empty object. Read-only original Rust/patina/dust/grime/scratch/chip/fading presets and exact GPU aging contract. UI activation opens the shared Aging tab; edits remain a saved render draft, never engineering geometry or material-property mutation."),
CommandSpec::new("render.effects.describe","Lens and Creative Effects",effects::presentation).icon("camera").noundo().doc_only()
.params("Empty object. Read-only original photographic/creative filter defaults and constraints. UI activation opens shared Effects controls. Saved finishing and exact-master regrading preserve raw EXR bytes; no physical lens or branded-film calibration is implied."),
CommandSpec::new("render.optics.bind","Save Optical Binding",optical_bindings::bind).icon("appearance")
.params("name: GPU setup; set?: complete numerical setup patch; body_index and body_name: exact captured target; expected_revision; optics:{ior,absorption_per_mm:[r,g,b],boundary:closed_mesh|closed_box|thin_sheet,thickness_mm,shadow_transmission,subsurface?:{scattering_per_mm,anisotropy}}; replace?:false. Stores stable definition/feature/occurrence identity in one undo without modifying engineering geometry/materials. Nested media require separate job.options.nested_media and producer certification. Unresolved or incompatible targets refuse atomically."),
CommandSpec::new("render.optics.unbind","Remove Optical Binding",optical_bindings::unbind).icon("appearance").doc_only()
.params("name: saved setup; target: exact saved definition/feature/component/occurrence_path; expected_revision. Removes only that optical assignment in one undo without a device or file access."),
CommandSpec::new("render.result.regrade", "Regrade Linear Master", jobs::regrade).icon("render_image").noundo().doc_only()
.params("job: exact completed JobId; presentation: {exposure_ev?,tone_map?,finishing?,backdrop?,background?,transparent_background?}. Queues cancellable nondestructive PNG presentation from that retained native FLOAT32 master, not from PNG. Captured geometry/camera/lighting and source EXR remain unchanged; no path tracing, denoising, external EXR import or automatic file writes. Returns a distinct job with source identity and original-master digest. Existing frame retention and export rules still apply."),
CommandSpec::new("render.camera.set", "Set Render Camera", set_camera).icon("camera").noundo().doc_only()
.params("camera: complete Render Camera snapshot (target, yaw, pitch, distance, fov; roll defaults to 0 and up to z). Validates the same finite mm/radian bounds as saved render intent, preserves explicit roll/up and changes only transient view state. No model evaluation, setup save, renderer, dirty flag or undo step. Design view.apply retains its separate up-axis policy."),
CommandSpec::new("render.camera.get", "Inspect Render Camera", get_camera).icon("camera").noundo()
.params("No parameters. Returns the explicitly defined current view camera, or null if the session has no camera override. No camera is fitted, no job is started and no saved setup or engineering document is changed."),
CommandSpec::new("render.camera.fit", "Frame Render Subject", fit_camera).icon("camera").noundo()
.params("No parameters. Fits the current camera to the visible product bounds through the shared Camera::fit contract; changes only view state, not a saved setup, rendering job, stage or engineering geometry."),
CommandSpec::new("render.preset.resolve", "Review Native Studio", studio_presets::resolve).icon("render_scene").noundo()
.params("id: NATIVE-STUDIO-001..025; name; set?: typed GPU setup patch; expected_revision?:u64. Resolves original built-in HDR/rig/passive ground with conservative geometry clearance, returns all replacements and preserved fields without mutation. Unbound SCENE-* concepts are not accepted."),
CommandSpec::new("render.preset.apply", "Apply Native Studio", studio_presets::apply).icon("render_scene")
.params("Same reviewed input as render.preset.resolve, expected_revision required, replace_lighting_and_environment:true and replace_stage:true when an existing stage would change. Re-resolves the complete candidate and publishes one undo or no mutation; camera, optical bindings, geometry and engineering materials are preserved."),
CommandSpec::new("render.quality.prepare", "Review Fixed Sampling Budget", studio_presets::quality).icon("settings").noundo().doc_only()
.params("id: Q-*; variant:fixed_samples_png; acknowledge_omissions:true. Read-only bounded GPU still-budget projection, not full adaptive/convergence/linear-master quality acceptance. Returns dimensions/samples/depth/seed/denoise and explicit omissions. Never switches executor or mutates a shot."),
CommandSpec::new("render.shot.duplicate", "Duplicate Render Shot", shots::duplicate).icon("copy").doc_only()
.params("name, new_name, expected_revision:u64. Copies exact saved setup and resource references in one undo. Existing destinations refuse; captured jobs and camera navigation are unchanged."),
CommandSpec::new("render.shot.rename", "Rename Render Shot", shots::rename).icon("settings").doc_only()
.params("name, new_name, expected_revision:u64. Renames exact saved setup in one undo; an existing destination or stale review refuses."),
CommandSpec::new("render.shot.remove", "Remove Render Shot", shots::remove).icon("delete").doc_only()
.params("name, expected_revision:u64. Removes only that saved setup in one undo; retains resources, geometry and captured results."),
CommandSpec::new("render.library.preview", "Inspect Original Surface Map", library::preview).icon("appearance").noundo().doc_only()
.params("id: SURF-*. Returns bounded hash-verified original base-color PNG bytes and source-map fidelity label, not a rendered material thumbnail. No external paths, rendering or document edits."),
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", physical_library::prepare).icon("inspect").noundo().doc_only()
.params("id: named built-in physical profile, name: proposed new material label, acknowledge_reference:true. Read-only loss-aware creation plan using native ValidatedCard admission and one point density; returns source receipts, extended fields and omissions. Does not execute material.create, assign bodies or infer effective-part physics."),
CommandSpec::new("render.stage.ground", "Save Render Ground", staging::ground).icon("render_scene")
.params("name: typed raytrace setup, set?: setup patch, margin_fraction?:0.25, gap_mm?:0.1, reflectance_linear?:[0.18,0.18,0.18], replace_stage?:false. Fits one finite passive ground below the visible product once and saves its physical transforms in one undo. Product AABB separation is rechecked on every render."),
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", staging::backdrop).icon("render_scene").doc_only()
.params("name: typed raytrace setup, set?: setup patch, kind: floor_wall or cyclorama, reflectance_linear?: [0.18,0.18,0.18], expected_revision: reviewed revision, replace_stage?:false. Fits a finite floor/wall or a 24-strip polygonal cyclorama behind the visible subject using the saved camera azimuth; world +Z up and a minimum one-mm stage envelope. Preserves subject geometry, lighting, output, pose and engineering materials; one undo and rechecked product separation. Not an infinite/analytically smooth/terrain background."),
CommandSpec::new("render.stage.clear", "Remove Render Stage", staging::clear).icon("render_scene").doc_only()
.params("name: setup, set?: setup patch. Removes saved render-only panels atomically; CAD geometry and lighting are unchanged."),
CommandSpec::new("render.library.surface.bind", "Save Surface Binding", surface_bindings::bind).icon("appearance")
.params("name: GPU setup; set?: setup patch; surface: same input as render.library.surface.prepare; replace?: false. Pins exact source hashes and definition/feature/occurrence identity with local-mm mapping in one undo. No paths, mesh ordinals or physical material changes."),
CommandSpec::new("render.library.surface.unbind", "Remove Surface Binding", surface_bindings::unbind).icon("appearance").doc_only()
.params("name: setup, target: saved definition/feature/component/occurrence_path, set?: setup patch. Removes only that explicit binding in one undo; no device or current geometry required."),
CommandSpec::new("render.result.pin", "Retain Displayed Render Result", jobs::pin).icon("inspect").noundo().doc_only()
.params("job: completed JobId, or null to release the displayed-result pin. Retains at most one completed result independently of a newer completion; preserves exact export bytes and original host memory accounting. No design/undo change; preview or foreign jobs refuse."),
CommandSpec::new("render.library.targets", "Find Render Library Targets", library::targets).icon("list").noundo()
.params("query?: exact-name substring; offset?:0; limit?:128 (1..128). Returns a bounded page of captured body ordinals/names, revision and scene bounds. Settles the model through ordinary command handling; never returns a large mesh payload."),
CommandSpec::new("render.library.list", "Browse Render Library", library::list).icon("list").noundo().doc_only()
.params("kind?: surfaces, selene_appearances, hdr, rigs, scenes, materials, lighting, environments, quality, native_scenes, selene_shots or capabilities; query?: bounded search; availability?: all/usable/specification/blocked; offset?:0; limit?:48 (1..128). Returns source status and explicit implemented projections separately. No device, file IO or design change."),
CommandSpec::new("render.library.describe", "Inspect Render Library Resource", library::describe).icon("inspect").noundo().doc_only()
.params("kind, id: exact authored resource. Returns original specification, runtime projection variants and explicit omissions. A concept is not an executable preset."),
CommandSpec::new("render.library.appearance.apply", "Apply Library Opaque Appearance", library::apply_appearance).icon("appearance")
.params("id: SURF-* or SELENE-AP-*; variant: uniform_opaque (required); bodies: 1..256 exact body names. Applies the original scalar baseline through ordinary appearance assignment in one undo, preserving physical properties. Spatial maps and advanced target effects are explicitly excluded."),
CommandSpec::new("render.library.environment.apply", "Apply Built-in HDR Environment", library::apply_environment).icon("settings").doc_only()
.params("id: HDR-*; name: saved setup; set?: typed CPU/GPU setup patch; rotation_rad?: authored rotation (native origin offset is explicit in the reply); intensity?:1; samples?:16 for CPU; replace_environment?:false. Hash-verifies and embeds original bytes, saves one atomic undo without external files or a render device. An HDR-owned sun cannot duplicate an analytic sun."),
CommandSpec::new("render.library.surface.prepare", "Prepare Authored Surface Maps", library::prepare_surface).icon("appearance").noundo()
.params("id: SURF-*; body_index and body_name from render.scene; origin?:[0,0,0]; plane?:xy/yz/zx; scale?:1. Returns an explicit GPU planar map binding with source hashes, original millimetre scale, image-up normal orientation and absolute base-color semantics. No document change; height/displacement and true anisotropy are not included."),
CommandSpec::new("render.library.studio.prepare", "Prepare Authored Studio Lights", library::prepare_studio).icon("inspect").noundo().doc_only()
.params("id: RIG-*; variant: complete for emitter-only rigs, emitters_only for rigs with passive cards; center_mm and subject_extent_mm required. Converts exact rectangular half-edges and solar direction/radius to GPU lighting. Blocked calibration and external-sun rigs refuse. Returns a geometry-clearance review requirement; does not create cards or modify the scene."),
CommandSpec::new("render.library.studio.apply", "Apply Authored Studio Lights", library::apply_studio).icon("settings")
.params("rig: render.library.studio.prepare parameters; name; set?: typed GPU setup patch; replace_lighting_and_environment:true required. Saves the explicit rig projection in one undo, removing prior lighting/environment only with that confirmation. Subject clearance still needs review; passive cards are never silently omitted."),
CommandSpec::new("render.hdr.inspect", "Inspect HDR Source", resources::inspect).icon("inspect").noundo().doc_only()
.params("path: scoped Radiance HDR file; validates the complete bounded source and returns sha256, width, height, encoded_bytes and primaries without saving or rendering"),
CommandSpec::new("render.settings.capture_hdr", "Save Render Settings with HDR", resources::capture_settings).icon("settings").doc_only()
.params("name, set: typed CPU/GPU setup patch with hdr/environment unset; path: scoped HDR; expected_sha256?: digest returned by render.hdr.inspect (changed sources refuse); rotation_rad?:0, intensity?:1; samples?:1/4/16/64 for CPU only (default16); assume_linear_srgb?:false for GPU only. Captures immutable bytes and saves resource plus setup atomically in one undo, without a host; returns name/setup/resource metadata, never source bytes"),
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only()
.params("setup?: saved name OR settings?: full RenderSetup. A typed job selects raster/cpu_raytrace/gpu_path and its numerical options plus embedded HDR before dispatch; only replace and transient studio_weights may accompany it. Without job, the legacy runtime controls below remain available. See docs/render-settings.md. studio_weights?: three finite 0..1 visibility weights for raster or implicit GPU studio sources only; never saved. integrator?: raster (default) or raytrace; progressive?: false (registered GPU path, CPU integrator/LED options are mutually exclusive); samples?: 1..4096; max_depth?: 1..16; seed?: u32 for progressive; raytrace?: bounded runtime progressive/primary_samples/lights/punctual_lights/area_lights/area_samples/seed/exposure_ev/shadows/reflections/anisotropic_faces/emissions/surface_effects/dielectrics/environment options, only with raytrace. CPU raytrace.budget?: compact (legacy default), studio or workstation; see docs/render-cpu-resource-profiles.md for explicit image/geometry/memory/work limits. CPU raytrace.progressive?: {samples_per_pixel:1..profile limit, seed:u32}; requires primary_samples:1 and selected reflections.samples:1; no area/LED, anisotropy or dielectrics in this initial mode. See docs/render-cpu-progressive.md; environment?: {path: scoped HDR, expected_sha256?: inspected digest, rotation_radians: 0, intensity: 1, samples: 16} OR {resource: saved HDR digest, rotation_radians, intensity, samples}; embedded resources cannot combine with path/hash; explicit sRGB/D65 HDR primaries required. Opaque CPU preview with selected mirror/rough-specular reflections and certified planar anisotropy. HDR with rough reflections or anisotropy requires fully metallic selected bodies and all effective faces; unsupported combinations refuse. No MIS or GI. CPU runtime dielectrics support only certified thin_sheet/closed_box boundaries with explicit IOR and Beer absorption; see docs/render-raytrace-dielectrics.md. CPU emissions are visible radiance only; surface_effects change roughness/shading normals without geometry changes. led_strip?: continuous world-mm LED rectangle recipe; led_samples?: 1/4/16/64 for the whole strip, default16. At most8directional+8punctual+4rectangle/LED sources; shared area N. See docs/render-light-union.md. transparent_background?: false; replace?: false. With progressive:true, raytrace instead accepts GPU dielectrics/clear_coats/textures; environment instead accepts tagged hdr/embedded_hdr/sky with rotation_rad/intensity; file hdr supports expected_sha256, embedded_hdr uses a document resource digest. See docs/render-hdr-resources.md. GPU lens, studio lighting, tone_map, denoise, bounded finishing/backdrop and linear_exr retention are also available. GPU adaptive?: {pilot_samples:16..256,min_samples:16..samples,relative_error:0..1,absolute_error:0.000001..1000000,coverage_error:0.001..0.25} allocates independent production budgets; pilots are discarded, not a certified error guarantee. transmission_samples?:1|2|4 stratifies the first Fresnel branch with extra path cost. volume?:{min_mm:[x,y,z],max_mm:[x,y,z],extinction_per_mm:0..1000,scattering_albedo:[r,g,b],anisotropy:-0.95..0.95} is one finite homogeneous world-mm medium; active media require explicit lighting/environment and refuse glass, denoise and multiple transmission strata. These fields also persist in GPU job.options. See docs/render-materials/TRANSPORT-MASTER-BATCH-2026-10-23.md; see docs/render-transmission.md, render-environment.md, render-runtime-textures.md, render-thin-lens.md, render-studio-lighting.md and render-denoise.md. Optional CPU raytracing requires a raytrace-cpu build and native host, checked before scene capture. Stale geometry is rejected. See docs/render-jobs.md"),
CommandSpec::new("render.start", "Render Image", jobs::start).key("R").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()
.params("job: {session, serial, generation}. Non-blocking progress, error, optional CPU preview metadata {frame_sequence,samples_per_pixel,frame}, and completed frame metadata, optional sampling allocation/work counts and retained-master presentation provenance; no image bytes and no rendering in this call. Preview is separate from the exportable final image."),
CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).icon("delete").noundo().doc_only()
.params("job: {session, serial, generation}. Requests cooperative cancellation; completed jobs remain completed. Late cancelled frames cannot be published."),
CommandSpec::new("render.result.copy", "Copy Render Image", jobs::copy).icon("copy").noundo().doc_only()
.params("job: completed JobId. Validates the retained PNG for app clipboard copying (max 4096 per side, 4194304 pixels); headless returns frame metadata only. Never copies a provisional image or starts a job."),
CommandSpec::new("render.export", "Export Render Image", jobs::export).icon("export").noundo().doc_only()
.params("job: {session, serial, generation}, path: destination matching format?: png (default) or exr, overwrite?: false. EXR requires an opt-in raw linear master retained by that completed GPU job; never converts PNG pixels. Exports a retained completed image atomically; an existing destination is preserved on failure. See docs/render-jobs.md"),
CommandSpec::new("render.backends", "Render Backends", backends).icon("list")
.noundo()
.doc_only()
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md"),
CommandSpec::new("render.settings.get", "Render Settings", get_settings).icon("settings").noundo().doc_only()
.params("name?: saved setup. Without name returns {setups, defaults}; with name returns {name, setup}. Does not evaluate the model or apply a camera. See docs/render-settings.md"),
CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).icon("settings").doc_only()
.params("name: setup name, set: object of fields to replace (backend, mode, camera, width, height, format, exposure_ev, environment_rotation, environment_intensity, background, job, surfaces, stage). job?: typed active raster/cpu_raytrace/gpu_path configuration with optional validated embedded HDR reference; omitted preserves, null clears, object replaces the complete job. Creates from defaults and saves without a host, validates atomically, one undo step; identical edits are no-ops. See docs/render-settings.md"),
CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("render_scene")
.params("No parameters. Authoritative product-scene metadata, without modelling overlays or mesh payloads. Explicitly settles stale geometry; does not render an image. See docs/render-workspace.md"),
CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).icon("camera").noundo().doc_only()
.params("name: saved setup. Applies its existing Camera snapshot including roll/up as view state, without modifying the document or starting a render."),
];
fn set_camera(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.camera.set";
if !params.as_object().is_some_and(|p| p.len() == 1 && p.contains_key("camera")) {
return Err(bad(cmd, "expected exactly one camera snapshot"));
}
// Reuse strict saved-camera decoding and its numeric limits, without saving a setup.
let setup: RenderSetup = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
s.camera = Some(setup.camera);
Ok(json!({"camera":setup.camera,"document_changed":false}))
}
fn get_camera(s: &mut Session, params: &Value) -> Result<Value> {
if !params.as_object().is_some_and(|p| p.is_empty()) {
return Err(bad("render.camera.get", "expected an empty parameter object"));
}
Ok(json!({"camera":s.camera,"explicit_view":s.camera.is_some(),"document_changed":false}))
}
fn fit_camera(s: &mut Session, params: &Value) -> Result<Value> {
if !params.as_object().is_some_and(|p| p.is_empty()) {
return Err(bad("render.camera.fit", "expected an empty parameter object"));
}
let scene = crate::product_scene::extract(s)?;
let mut camera = s.camera.unwrap_or_default();
if !scene.bounds.is_empty() {
camera.fit(&scene.bounds);
}
s.camera = Some(camera);
Ok(json!({"camera":camera,"empty":scene.bounds.is_empty(),"document_changed":false}))
}
fn scene(s: &mut Session, params: &Value) -> Result<Value> {
if !params.as_object().is_some_and(|p| p.is_empty()) {
return Err(bad("render.scene", "expected an empty parameter object"));
}
let scene = crate::product_scene::extract(s)?;
let mut stable = std::collections::BTreeMap::new();
for (target, name) in surface_bindings::targets(s, "render.scene")? {
if stable.insert(name, target).is_some() {
return Err(bad("render.scene", "ambiguous stable occurrence name"));
}
}
let bodies: Vec<_> = scene
.meshes
.iter()
.enumerate()
.map(|(ordinal,m)| json!({"ordinal":ordinal,"name":m.name,"stable_target":stable.get(&m.name),"triangles":m.mesh.triangles.len(),"color":m.color,"opacity":m.opacity,"face_appearances":m.faces.len(),"bounds":{"min":m.mesh.bounds().min,"max":m.mesh.bounds().max}}))
.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}),
)
}
fn apply_settings(s: &mut Session, params: &Value) -> Result<Value> {
let request: GetSettings = serde_json::from_value(params.clone()).map_err(|e| bad("render.settings.apply", e.to_string()))?;
let name = request.name.ok_or_else(|| bad("render.settings.apply", "`name` is required"))?;
RenderSettings::validate_name(&name).map_err(|e| bad("render.settings.apply", e.to_string()))?;
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad("render.settings.apply", "no saved render setup with that name"))?;
setup.validate().map_err(|e| bad("render.settings.apply", e.to_string()))?;
s.camera = Some(setup.camera);
Ok(json!({"name":name,"camera":setup.camera}))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct GetSettings {
name: Option<String>,
}
fn get_settings(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.settings.get";
let request: GetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
match request.name {
None => Ok(json!({"setups": s.doc.render.setups, "defaults": RenderSetup::default()})),
Some(name) => {
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad(cmd, "no saved render setup with that name"))?;
Ok(json!({"name": name, "setup": setup}))
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SetSettings {
name: String,
set: Value,
}
fn set_settings(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.settings.set";
let request: SetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
let setup = patched_settings(s, &request.name, &request.set, cmd)?;
let reply = json!({"name": request.name, "setup": setup});
if s.doc.render.setups.get(&request.name) == Some(&setup) {
return Ok(reply);
}
let mut settings = (*s.doc.render).clone();
settings.setups.insert(request.name, setup);
settings.validate().map_err(|e| bad(cmd, e.to_string()))?;
s.doc_mut().render = Arc::new(settings);
Ok(reply)
}
fn patched_settings(s: &Session, name: &str, set: &Value, cmd: &str) -> Result<RenderSetup> {
RenderSettings::validate_name(name).map_err(|e| bad(cmd, e.to_string()))?;
let patch = set.as_object().filter(|p| p.len() <= 13).ok_or_else(|| bad(cmd, "`set` must be an object with at most 13 fields"))?;
let current = s.doc.render.setups.get(name).cloned().unwrap_or_default();
let mut value = serde_json::to_value(&current).map_err(|e| bad(cmd, e.to_string()))?;
let object = value.as_object_mut().ok_or_else(|| bad(cmd, "invalid render setup"))?;
for (key, value) in patch {
object.insert(key.clone(), value.clone());
}
let setup: RenderSetup = serde_json::from_value(value).map_err(|e| bad(cmd, e.to_string()))?;
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
Ok(setup)
}
fn backends(s: &mut Session, params: &Value) -> Result<Value> {
if !params.as_object().is_some_and(|p| p.is_empty()) {
return Err(bad("render.backends", "expected an empty parameter object"));
}
Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [s.render_backend()],
"cpu_resource_profiles": solidcraft_render::job::CpuBudget::ALL.map(|budget| json!({"id":budget,"limits":budget.limits()}))}))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn assert_unchanged(s: &mut Session, params: Value) -> Result<Value> {
let doc = s.doc.clone();
let before = (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale());
let camera = s.camera;
let undo = s.undo.clone();
let redo = s.redo.clone();
let result = s.execute("render.backends", &params);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale()));
assert_eq!(camera, s.camera);
for (before, after) in [(&undo, &s.undo), (&redo, &s.redo)] {
assert_eq!(before.len(), after.len());
for (a, b) in before.iter().zip(after.iter()) {
assert!(Arc::ptr_eq(&a.doc, &b.doc));
assert_eq!((&a.label, a.active_sketch, &a.pending_moves), (&b.label, b.active_sketch, &b.pending_moves));
}
}
result
}
#[test]
fn discovery_preserves_clean_dirty_and_undo_redo_state() {
let mut s = Session::default();
let r = assert_unchanged(&mut s, Value::Null).unwrap();
assert_eq!(r["schema_version"], 1);
assert_eq!(r["default_backend"], "builtin");
assert_eq!(r["backends"].as_array().unwrap().len(), 1);
let descriptor: crate::render::BackendDescriptor = serde_json::from_value(r["backends"][0].clone()).unwrap();
assert_eq!(descriptor, crate::render::builtin_backend());
s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 5})).unwrap();
s.execute("view.apply", &json!({"view": "top"})).unwrap();
assert!(s.is_dirty());
assert_unchanged(&mut s, json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.redo.is_empty());
assert_unchanged(&mut s, json!({})).unwrap();
for p in [json!([]), json!(3), json!({"backend": "missing"}), json!({"width": "bad"})] {
assert!(assert_unchanged(&mut s, p).is_err());
}
}
#[test]
fn cooperative_host_never_discovers_cpu_raytracing_or_accepts_its_reserved_viewport_id() {
use solidcraft_render::job::CpuHost;
let mut s = Session::default();
s.set_render_job_host(Some(CpuHost::cooperative())).unwrap();
let reply = assert_unchanged(&mut s, json!({})).unwrap();
let backend: solidcraft_render::BackendDescriptor = serde_json::from_value(reply["backends"][0].clone()).unwrap();
assert!(backend.executors.iter().any(|d| d.id.as_str() == "cpu-product"));
assert!(!backend.executors.iter().any(|d| d.id.as_str() == "cpu-raytrace"));
let mut viewport = CpuHost::cooperative().descriptor().unwrap();
viewport.id = solidcraft_render::ExecutorId::new("cpu-raytrace").unwrap();
viewport.profiles[0].operation = solidcraft_render::RenderOperation::Viewport;
viewport.profiles[0].formats.clear();
assert!(s.set_render_viewport(Some(viewport)).unwrap_err().to_string().contains("reserved"));
assert_eq!(s.render_backend(), backend);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_cpu_discovery_matches_installed_features_and_preserves_raster_default() {
use solidcraft_render::job::{CpuHost, Integrator};
use solidcraft_render::{RenderFeature, RenderFormat, RenderMode, RenderOperation, RenderRequest};
let host = CpuHost::for_platform().unwrap();
// Query the host so the same test covers feature-off builds and workspace
// feature unification without assuming the engine's own cfg is sufficient.
let raytrace = host.validate_integrator(Integrator::Raytrace).is_ok();
let mut s = Session::default();
let doc = s.doc.clone();
s.set_render_job_host(Some(host.clone())).unwrap();
let reply = assert_unchanged(&mut s, json!({})).unwrap();
assert!(Arc::ptr_eq(&doc, &s.doc));
assert!(!s.is_dirty());
assert!(s.undo.is_empty());
let backend: solidcraft_render::BackendDescriptor = serde_json::from_value(reply["backends"][0].clone()).unwrap();
let descriptors: Vec<_> = backend.executors.iter().filter(|d| d.id.as_str() == "cpu-raytrace").collect();
assert_eq!(descriptors.len(), usize::from(raytrace));
if let Some(descriptor) = descriptors.first() {
assert_eq!(descriptor.availability, solidcraft_render::Availability::Available);
assert_eq!(*descriptor, &host.descriptor_for(Integrator::Raytrace).unwrap());
assert!(!descriptor.profiles.iter().any(|p| p.features.contains(&RenderFeature::Progressive)));
}
let request = RenderRequest {
backend: None,
mode: RenderMode::Preview,
operation: RenderOperation::Image,
width: 16,
height: 16,
format: Some(RenderFormat::Png),
features: vec![RenderFeature::Pbr],
policy: Default::default(),
};
assert_eq!(solidcraft_render::resolve_request(std::slice::from_ref(&backend), &request).unwrap().executor.as_str(), "cpu-product");
assert_eq!(s.scratch().render_backend(), backend);
s.set_render_job_host(Some(host)).unwrap();
assert_eq!(s.render_backend(), backend, "reinstalling the same host cannot duplicate method descriptors");
s.set_render_job_host(Some(CpuHost::cooperative())).unwrap();
assert!(!s.render_backend().executors.iter().any(|d| d.id.as_str() == "cpu-raytrace"));
s.set_render_job_host(None).unwrap();
assert_eq!(s.render_backend(), solidcraft_render::builtin_backend());
assert_unchanged(&mut s, json!({})).unwrap();
assert!(Arc::ptr_eq(&doc, &s.doc));
assert!(!s.is_dirty());
assert!(s.undo.is_empty());
}
#[test]
fn discovery_does_not_settle_a_live_drag_or_rebuild_geometry() {
let mut s = Session::default();
for (id, p) in [
("sketch.create", json!({"plane": "XY"})),
("sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]})),
("sketch.dimension", json!({"entities": ["l1"], "value": 20})),
("sketch.dimension", json!({"entities": ["l2"], "value": 15})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 5})),
("sketch.edit", json!({"sketch": "Sketch1"})),
("sketch.drag", json!({"entity": "l1.start", "from": [5, 5], "to": [4, 5], "live": true})),
] {
s.execute(id, &p).unwrap();
}
assert!(s.model.is_stale());
s.evaluations = 0;
let model = s.model.state();
assert_unchanged(&mut s, json!({})).unwrap();
assert_unchanged(&mut s, json!({"unexpected": true})).unwrap_err();
assert!(Arc::ptr_eq(&model, &s.model.state()));
assert_eq!(s.evaluations, 0);
}
}
#[cfg(test)]
mod settings_tests;
#[cfg(test)]
mod job_tests;
pub(super) fn prepare_interactive(s: &crate::Session, params: &serde_json::Value) -> crate::Result<crate::render_interactive::Prepared> {
jobs::interactive(s, params)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,338 @@
//! Planned Render interactions, with explicit non-mutating refusals.
use super::CommandSpec;
use crate::schema::render_lights::{MuteParams, SoloParams};
use crate::schema::{
TypedContract, output_schema_for,
render_ui::{CheckReport, InteractiveParams},
};
use crate::{Result, Session, params::bad};
use serde::Deserialize;
use serde_json::{Value, json};
const SOLO: crate::schema::TypedContract<SoloParams> = crate::schema::TypedContract::new();
const MUTE: crate::schema::TypedContract<MuteParams> = crate::schema::TypedContract::new();
fn solo(_: &mut crate::Session, params: &serde_json::Value) -> crate::Result<serde_json::Value> {
let command = "render.lights.solo";
if !params.is_object() {
return Err(crate::params::bad(command, "expected an object"));
}
let input: SoloParams = serde_json::from_value(params.clone()).map_err(|e| crate::params::bad(command, e.to_string()))?;
if input.solo && input.id.is_none() {
return Err(crate::params::bad(command, "solo requires a light id"));
}
if let Some(id) = &input.id {
crate::render_lights::parse_id(id).map_err(|e| crate::params::bad(command, e))?;
}
serde_json::to_value(input).map_err(|e| crate::params::bad(command, e.to_string()))
}
fn mute(_: &mut crate::Session, params: &serde_json::Value) -> crate::Result<serde_json::Value> {
let command = "render.lights.mute";
if !params.is_object() {
return Err(crate::params::bad(command, "expected an object"));
}
let input: MuteParams = serde_json::from_value(params.clone()).map_err(|e| crate::params::bad(command, e.to_string()))?;
crate::render_lights::parse_id(&input.id).map_err(|e| crate::params::bad(command, e))?;
serde_json::to_value(input).map_err(|e| crate::params::bad(command, e.to_string()))
}
macro_rules! waiting {
($id:literal, $label:literal, $tab:literal, $panel:literal, $icon:literal, $reason:literal) => {
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $reason).into())))
.enabled(|_| Err(concat!("not supported yet: ", $reason).into()))
.planned("5493")
.noundo()
.doc_only()
.at($tab, $panel)
.icon($icon)
.params("No supported parameters. Always refuses without changing the design or history.")
};
}
const CHECK_REPORT: TypedContract<CheckReport> = TypedContract::new();
const INTERACTIVE: TypedContract<InteractiveParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("render.interactive", "Interactive Render", crate::checked!("render.interactive", interactive))
.icon("render_image")
.contract(&INTERACTIVE)
.output(output_schema_for::<InteractiveParams>)
.noundo()
.doc_only()
.params("action: on|off|pause|resume. Validates and echoes an explicit CPU Interactive preview request for the UI adapter. Headless calls never start, pause or cancel a render job, open UI, or change the design, final render settings or exportable images."),
CommandSpec::new("render.preflight.copy", "Copy Render Check Report", crate::checked!("render.preflight.copy", copy_check))
.icon("copy")
.contract(&CHECK_REPORT)
.output(output_schema_for::<CheckReport>)
.noundo()
.doc_only()
.params("text: nonempty report, at most 65536 bytes. Validated clipboard request; headless echo only, never renders or edits a design."),
CommandSpec::new("render.output.size", "Set Render Output Size", output_size)
.icon("settings")
.noundo()
.doc_only()
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job."),
CommandSpec::new("render.result.view", "View Render Image", result_view)
.icon("render_image")
.noundo()
.doc_only()
.params("view: model|image|fit|actual. Validated result canvas request; headless echo only, never starts a job."),
CommandSpec::new("render.result.close", "Close Render Image", close_result)
.icon("close")
.key("Escape")
.noundo()
.doc_only()
.params("No parameters. Return to Model Preview; headless echo only, never cancels a job."),
CommandSpec::new("appearance.paint", "Paint Appearance", super::inspect::appearance_gestures::paint)
.icon("appearance").key("B").noundo()
.params("appearance or color/name/opacity/pbr: appearance.assign look parameters; validates and returns look for ui.start paint. done?: true ends the UI tool. Programmatic calls never open UI."),
CommandSpec::new("appearance.pick_up", "Pick Up Appearance", super::inspect::appearance_gestures::pick_up)
.icon("select").key("I").noundo()
.params("target?: appearance.chain target; returns its resolved look, or requests the UI sampling tool when omitted. Programmatic calls never open UI."),
CommandSpec::new("render.lights.solo","Solo Light",crate::checked!("render.lights.solo",solo))
.noundo().doc_only().icon("render_scene").at("RENDER SCENE","LIGHTS")
.contract(&SOLO).output(crate::schema::output_schema_for::<SoloParams>)
.params("id?: kind:slot light address; solo:bool. Enabling requires an id; false clears solo. Validates and echoes per-document UI view state. Never saves settings, evaluates geometry, opens an editor or changes history."),
CommandSpec::new("render.lights.mute","Mute Light",crate::checked!("render.lights.mute",mute))
.noundo().doc_only().icon("render_scene").at("RENDER SCENE","LIGHTS")
.contract(&MUTE).output(crate::schema::output_schema_for::<MuteParams>)
.params("id: kind:slot light address; mute:bool. Validates and echoes per-document UI view state, applied to live preview and captured render copies only. Never changes saved settings or history."),
waiting!("render.compare", "Compare Images", "RENDER", "RESULT", "compare", "retained image comparison"),
waiting!("render.batch", "Render All Shots", "RENDER", "BATCH", "render_image", "the saved-shot render queue"),
];
fn copy_check(_: &mut Session, params: &Value) -> Result<Value> {
let report: CheckReport = serde_json::from_value(params.clone()).map_err(|e| bad("render.preflight.copy", e.to_string()))?;
if report.text.is_empty() || report.text.len() > 65536 {
return Err(bad("render.preflight.copy", "report must contain 1..65536 bytes"));
}
Ok(json!({"text":report.text}))
}
#[cfg(test)]
#[test]
fn render_lights_remaining_planned_commands_refuse_without_mutation() {
use serde_json::{Value, json};
assert!(super::find_command("appearance.replace").is_some_and(|command| command.planned.is_none()));
let mut session = crate::Session::default();
session.execute("render.settings.set", &json!({"name":"Studio","set":{"width":800}})).unwrap();
session.execute("render.settings.set", &json!({"name":"Studio","set":{"width":900}})).unwrap();
session.execute("edit.undo", &json!({})).unwrap();
let document = session.doc.clone();
let before = (session.undo.len(), session.redo.len(), session.revision, session.evaluations, session.is_dirty());
for command in COMMANDS {
if command.planned.is_none() {
continue;
}
assert!(!command.info(&session).enabled);
for params in [json!({}), Value::Null, json!([]), json!({"target":1e300,"name":"hostile"})] {
let error = session.execute(command.id, &params).unwrap_err().to_string();
assert!(error.contains("not supported yet"), "{error}");
assert!(std::sync::Arc::ptr_eq(&document, &session.doc));
assert_eq!(before, (session.undo.len(), session.redo.len(), session.revision, session.evaluations, session.is_dirty()));
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OutputSize {
width: u32,
height: u32,
#[serde(default)]
custom: bool,
}
fn output_size(_: &mut Session, params: &Value) -> Result<Value> {
let input: OutputSize = serde_json::from_value(params.clone()).map_err(|e| bad("render.output.size", e.to_string()))?;
if !(1..=16384).contains(&input.width) || !(1..=16384).contains(&input.height) {
return Err(bad("render.output.size", "dimensions must be in 1..16384"));
}
let mut reply = json!({"width":input.width,"height":input.height});
if input.custom {
reply["custom"] = json!(true);
}
Ok(reply)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ResultView {
view: String,
}
fn result_view(_: &mut Session, params: &Value) -> Result<Value> {
let input: ResultView = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.view", e.to_string()))?;
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual") {
return Err(bad("render.result.view", "view must be model, image, fit or actual"));
}
Ok(json!({"view":input.view}))
}
fn close_result(_: &mut Session, params: &Value) -> Result<Value> {
if !params.as_object().is_some_and(|p| p.is_empty()) {
return Err(bad("render.result.close", "expected no parameters"));
}
Ok(json!({"view":"model"}))
}
#[cfg(test)]
#[test]
fn output_viewer_requests_are_validated_headless_echoes() {
let mut s = Session::default();
let document = s.doc.clone();
let before = (s.revision, s.undo.len(), s.evaluations);
for size in [json!({"width":1280,"height":720}), json!({"width":1920,"height":1080})] {
assert_eq!(s.execute("render.output.size", &size).unwrap(), size);
}
let custom = json!({"width":1920,"height":1080,"custom":true});
assert_eq!(s.execute("render.output.size", &custom).unwrap(), custom);
assert!(s.execute("render.output.size", &json!({"width":1920,"height":1080,"custom":"yes"})).is_err());
for view in ["model", "image", "fit", "actual"] {
let request = json!({"view":view});
assert_eq!(s.execute("render.result.view", &request).unwrap(), request);
}
assert_eq!(s.execute("render.result.close", &json!({})).unwrap(), json!({"view":"model"}));
for request in
[json!(null), json!({}), json!({"width":0,"height":1}), json!({"width":u64::MAX,"height":1}), json!({"width":1,"height":1,"extra":true})]
{
assert!(s.execute("render.output.size", &request).is_err());
}
for request in [json!(null), json!({"view":"unknown"}), json!({"view":"image","extra":true})] {
assert!(s.execute("render.result.view", &request).is_err());
// Session normalizes null parameters to an empty object for no-argument commands.
if request.is_object() {
assert!(s.execute("render.result.close", &request).is_err());
}
}
assert!(std::sync::Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.evaluations));
}
#[cfg(test)]
#[test]
fn output_sections_copy_report_validates_without_design_history_or_job_changes() {
let mut s = Session::default();
let doc = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty());
let report = json!({"text":"Check\n✓ Size is within the renderer limit"});
assert_eq!(s.execute("render.preflight.copy", &report).unwrap(), report);
for request in [json!({}), json!({"text":""}), json!({"text":true}), json!({"text":"check","job":1}), json!({"text":"x".repeat(65537)})] {
assert!(s.execute("render.preflight.copy", &request).is_err());
}
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
}
/// The UI adapter owns provisional preview actions; headless execution only echoes.
fn interactive(_: &mut Session, params: &Value) -> Result<Value> {
let command = "render.interactive";
if !params.is_object() {
return Err(bad(command, "expected an object"));
}
let request: InteractiveParams = serde_json::from_value(params.clone()).map_err(|error| bad(command, error.to_string()))?;
serde_json::to_value(request).map_err(|error| bad(command, error.to_string()))
}
#[cfg(test)]
#[test]
fn interactive_contract_describes_exact_actions_and_a_nonmutating_echo() {
use crate::schema::{input_schema_for, render_ui::Action};
let command = super::find_command("render.interactive").unwrap();
assert!(!command.undoable);
assert!(!command.reads_model);
assert!(command.planned.is_none());
assert_eq!(command.icon, "render_image");
let contract = command.contract.unwrap();
assert_eq!(contract.allowed_keys().iter().map(String::as_str).collect::<Vec<_>>(), ["action"]);
assert!(command.output.is_some());
let action_schema = serde_json::to_value(input_schema_for::<Action>()).unwrap();
assert_eq!(action_schema["enum"], json!(["on", "off", "pause", "resume"]));
let request_schema = serde_json::to_value(input_schema_for::<InteractiveParams>()).unwrap();
assert_eq!(request_schema["additionalProperties"], false);
assert_eq!(request_schema["required"], json!(["action"]));
let reply_schema = serde_json::to_value(output_schema_for::<InteractiveParams>()).unwrap();
assert_eq!(reply_schema["required"], json!(["action"]));
assert_eq!(reply_schema["properties"].as_object().unwrap().keys().map(String::as_str).collect::<Vec<_>>(), ["action"]);
}
#[cfg(test)]
#[test]
fn interactive_valid_and_hostile_requests_preserve_headless_document_history_and_host() {
use crate::schema::ParamCheck;
let mut session = Session::default();
session.execute("render.settings.set", &json!({"name":"Original","set":{"width":800}})).unwrap();
session.execute("render.settings.set", &json!({"name":"Original","set":{"width":900}})).unwrap();
session.execute("edit.undo", &json!({})).unwrap();
let document = session.doc.clone();
let before = (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty());
assert!(session.render_job_host().is_none());
for level in [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict] {
session.param_check = level;
for action in ["on", "off", "pause", "resume"] {
let params = json!({"action":action});
assert_eq!(session.execute("render.interactive", &params).unwrap(), params);
}
for hostile in [
json!(null),
json!(true),
json!(0),
json!("on"),
json!([]),
json!(["on"]),
json!({}),
json!({"action":null}),
json!({"action":true}),
json!({"action":1e300}),
json!({"action":["on"]}),
json!({"action":{"on":true}}),
json!({"action":"start"}),
json!({"action":"On"}),
json!({"action":" on"}),
json!({"action":"on\n"}),
json!({"action":"x".repeat(65536)}),
json!({"action":"on","slot":0}),
json!({"action":"off","job":{"session":1,"serial":1,"generation":1}}),
json!({"action":"pause","replace":true}),
json!({"action":"resume","settings":{}}),
json!({"action":"on","path":"must-not-be-read.hdr"}),
] {
assert!(session.execute("render.interactive", &hostile).is_err(), "{level:?}: {hostile}");
}
assert!(std::sync::Arc::ptr_eq(&document, &session.doc));
assert_eq!(before, (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty()));
assert!(session.render_job_host().is_none());
}
}
#[cfg(test)]
#[test]
fn interactive_echo_preserves_a_queued_final_job_and_its_resource_lease() {
use solidcraft_render::job::{CpuHost, JobId};
let host = CpuHost::cooperative();
let mut session = Session::default();
session.set_render_job_host(Some(host.clone())).unwrap();
// Queue a tiny raster job without pumping the cooperative host or opening a device.
let started = session.execute("render.start", &json!({"settings":{"width":16,"height":16}})).unwrap();
let first_id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let first = session.render_job_handle(first_id).unwrap();
let status = serde_json::to_value(first.status().unwrap()).unwrap();
let admitted = host.admitted_bytes();
assert!(admitted > 0);
let document = session.doc.clone();
let before = (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty());
for action in ["on", "pause", "resume", "off"] {
let params = json!({"action":action});
assert_eq!(session.execute("render.interactive", &params).unwrap(), params);
assert_eq!(serde_json::to_value(first.status().unwrap()).unwrap(), status);
assert_eq!(host.admitted_bytes(), admitted);
assert!(session.render_job_host().unwrap().same_host(&host));
assert!(std::sync::Arc::ptr_eq(&document, &session.doc));
assert_eq!(before, (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty()));
}
assert!(session.execute("render.interactive", &json!({"action":"off","job":first_id})).is_err());
assert_eq!(serde_json::to_value(first.status().unwrap()).unwrap(), status);
assert_eq!(host.admitted_bytes(), admitted);
let next = session.execute("render.start", &json!({"settings":{"width":16,"height":16}})).unwrap();
let next_id: JobId = serde_json::from_value(next["job"].clone()).unwrap();
assert_eq!(next_id.session, first_id.session);
assert_eq!(next_id.serial, first_id.serial + 1, "echo actions must not allocate or replace final job identities");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,402 @@
//! Transient CPU Interactive capture, separate from final render ownership/export.
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use serde_json::Value;
use solidcraft_render::job::{
self, ErrorCode, JobError, JobHandle,
interactive::{CaptureTicket, Interactive, State, VisualRevision},
};
use crate::{Result, Session, render_jobs::job_error};
pub(crate) type Controller = Mutex<Interactive>;
/// A poisoned lifecycle is never resumed. Cleanup only its owned work, then fail.
pub(crate) fn with_controller<T>(controller: &Controller, f: impl FnOnce(&mut Interactive) -> job::Result<T>) -> job::Result<T> {
match controller.lock() {
Ok(mut state) => f(&mut state),
Err(poisoned) => {
let _ = poisoned.into_inner().disable();
Err(JobError::new(ErrorCode::Internal, "Interactive lifecycle lock poisoned"))
}
}
}
/// One transient controller for the current document/service registration.
/// Only Session::interactive_render allocates its dedicated preview namespace.
pub struct InteractiveRender {
document_uid: u64,
preview_session: u64,
controller: Arc<Controller>,
}
impl InteractiveRender {
pub(crate) fn new(document_uid: u64, preview_session: u64, final_active: bool) -> job::Result<Self> {
if document_uid == 0 || preview_session == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "Interactive identity exhausted"));
}
let mut state = Interactive::new(preview_session);
state.set_final_active(final_active, Duration::ZERO)?;
Ok(Self { document_uid, preview_session, controller: Arc::new(Mutex::new(state)) })
}
pub(crate) fn weak(&self) -> Weak<Controller> {
Arc::downgrade(&self.controller)
}
fn with<T>(&self, f: impl FnOnce(&mut Interactive) -> job::Result<T>) -> Result<T> {
with_controller(&self.controller, f).map_err(job_error)
}
/// Cheap ownership metadata only: no geometry, files, host discovery or work.
pub fn is_current(&self, session: &Session) -> bool {
self.document_uid == session.uid && session.render_jobs.is_interactive_current(&self.weak())
}
pub fn document_uid(&self) -> u64 {
self.document_uid
}
pub fn enable(&self, revision: VisualRevision, now: Duration) -> Result<()> {
self.with(|state| state.enable(revision, now))
}
pub fn edit(&self, revision: VisualRevision, now: Duration) -> Result<()> {
self.with(|state| state.edit(revision, now))
}
pub fn disable(&self) -> Result<()> {
self.with(Interactive::disable)
}
pub fn pause(&self) -> Result<()> {
self.with(Interactive::pause)
}
pub fn resume(&self, now: Duration) -> Result<()> {
self.with(|state| state.resume(now))
}
pub fn set_final_active(&self, active: bool, now: Duration) -> Result<()> {
self.with(|state| state.set_final_active(active, now))
}
pub fn capture(&self, now: Duration) -> Result<Option<CaptureTicket>> {
self.with(|state| state.capture(now))
}
pub fn state(&self) -> Result<State> {
self.with(|state| state.state())
}
pub fn current(&self) -> Result<Option<JobHandle>> {
self.with(|state| Ok(state.current().cloned()))
}
/// Capture failures live in the controller; worker failures live in its job.
pub fn error(&self) -> Result<Option<String>> {
self.with(|state| {
if let Some(error) = state.error() {
return Ok(Some(error.to_string()));
}
let status = state.current().map(JobHandle::status).transpose()?;
Ok(status.and_then(|status| status.error).map(|error| error.to_string()))
})
}
pub fn capture_failed(&self, ticket: CaptureTicket, message: String) -> Result<()> {
self.with(|state| {
state.capture_failed(ticket, JobError::new(ErrorCode::InvalidScene, message));
Ok(())
})
}
fn reject_snapshot(&self, ticket: CaptureTicket, error: JobError) -> Result<Snapshot> {
self.with(|state| {
state.capture_failed(ticket, error.clone());
Ok(())
})?;
Err(job_error(error))
}
/// Freeze one complete UI intent and document. The caller supplies the same
/// immutable numerical intent that produced this visual revision/ticket.
/// A name, CAD revision or reused Value alone is not visual provenance.
pub fn snapshot(&self, session: &Session, ticket: CaptureTicket, params: &Value) -> Result<Snapshot> {
if !self.is_current(session) || ticket.job_id().session != self.preview_session || ticket.revision().scene_generation != session.revision {
return self.reject_snapshot(
ticket,
JobError::new(ErrorCode::WrongOwner, "Interactive capture belongs to another document or service revision"),
);
}
if session.model.is_stale() {
return self.reject_snapshot(ticket, JobError::new(ErrorCode::InvalidScene, "finish the modelling gesture before Interactive capture"));
}
if !self.with(|state| Ok(state.accepts_capture(ticket)))? {
return self.reject_snapshot(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive capture ticket is no longer current"));
}
Ok(Snapshot {
session: session.scratch(),
params: params.clone(),
ticket,
controller: self.weak(),
scope: solidcraft_io::vfs::current_scope(),
})
}
}
impl Drop for InteractiveRender {
fn drop(&mut self) {
let _ = self.disable();
}
}
/// Immutable capture envelope; no engine worker or queue is created here.
/// The caller maintains one app-wide capture worker, including across tab swaps.
pub struct Snapshot {
session: Session,
params: Value,
ticket: CaptureTicket,
controller: Weak<Controller>,
scope: Option<solidcraft_io::vfs::PathScope>,
}
/// Internal output of the ordinary authoritative CPU preparation boundary.
pub(crate) struct Prepared {
pub(crate) scene: solidcraft_render::ProductScene,
pub(crate) request: job::ImageRequest,
pub(crate) options: job::JobOptions,
pub(crate) environment: Option<solidcraft_render::lighting::environment_input::EnvironmentInput>,
}
/// This guard only receives a record just created by this capture, never a
/// caller's retained/manual/foreign handle. Dropping a JobHandle alone is not a
/// cancellation request: native queue/running work keeps its own clone.
struct Unadopted {
job: JobHandle,
adopted: bool,
}
impl Drop for Unadopted {
fn drop(&mut self) {
if !self.adopted {
let _ = self.job.cancel();
}
}
}
/// Adopt a newly submitted local record. Even an equal-ID duplicate is cancelled
/// when rejected: it is a different new record, not a copy of the accepted job.
pub(crate) fn adopt_created(controller: &Controller, ticket: CaptureTicket, job: JobHandle) -> job::Result<bool> {
if job.id().session != ticket.job_id().session {
return Err(JobError::new(ErrorCode::WrongOwner, "created Interactive record has another identity"));
}
let mut pending = Unadopted { job, adopted: false };
let accepted = with_controller(controller, |state| state.attach(ticket, pending.job.clone()))?;
pending.adopted = accepted;
Ok(accepted)
}
impl Snapshot {
/// Correlate the caller's receiver and discard old errors after tab changes.
pub fn ticket(&self) -> CaptureTicket {
self.ticket
}
pub fn document_uid(&self) -> u64 {
self.session.uid
}
/// Invoke beside the window, never while holding a lifecycle lock. Both
/// preparation and admission may be long; edits/finals can invalidate either.
pub fn run(mut self) -> Result<bool> {
let Some(controller) = self.controller.upgrade() else { return Ok(false) };
if !with_controller(&controller, |state| Ok(state.accepts_capture(self.ticket))).map_err(job_error)? {
return Ok(false);
}
let result = (|| {
let prepared = solidcraft_io::vfs::scoped(self.scope.take(), || crate::cmd::prepare_interactive(&self.session, &self.params))?;
if !with_controller(&controller, |state| Ok(state.accepts_capture(self.ticket))).map_err(job_error)? {
return Ok(false);
}
let host = self
.session
.render_job_host()
.ok_or_else(|| job_error(JobError::new(ErrorCode::HostUnavailable, "CPU image host is unavailable")))?;
let job = host
.submit_with_environment_and_options(
self.ticket.job_id(),
self.ticket.revision().scene_generation,
prepared.scene,
prepared.request,
prepared.options,
prepared.environment,
)
.map_err(job_error)?;
adopt_created(&controller, self.ticket, job).map_err(job_error)
})();
if let Err(error) = &result {
let _ = with_controller(&controller, |state| {
state.capture_failed(self.ticket, JobError::new(ErrorCode::InvalidScene, error.to_string()));
Ok(())
});
}
result
}
}
/// Opaque visual-source stamp: no placed world, mesh, strong document or B-rep
/// ownership. Construct only after the previous stamp stops matching; retain it
/// and call is_current on each frame instead of cloning session metadata again.
pub struct SceneIdentity {
uid: u64,
revision: u64,
document: Weak<solidcraft_doc::Document>,
model: Weak<solidcraft_doc::ModelState>,
pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
active_sketch: Option<u64>,
active_component: u64,
active_occurrence_path: Option<Vec<u64>>,
visibility: solidcraft_doc::Visibility,
}
impl SceneIdentity {
/// Exact metadata comparison. model.state only clones the existing evaluated
/// Arc; it neither evaluates nor places bodies nor obtains a display mesh.
/// Weak allocation identities cannot alias a later allocation while retained.
pub fn is_current(&self, session: &Session) -> bool {
self.uid == session.uid
&& self.revision == session.revision
&& Weak::ptr_eq(&self.document, &Arc::downgrade(&session.doc))
&& Weak::ptr_eq(&self.model, &Arc::downgrade(&session.model.state()))
&& self.pending_moves == session.pending_moves
&& self.active_sketch == session.active_sketch
&& self.active_component == session.active_component
&& self.active_occurrence_path == session.active_occurrence_path
&& self.visibility == session.visibility
}
}
impl Session {
/// Source identity without computing cold assembly placements or meshes.
/// Stale modelling gestures must still be rejected separately before capture.
/// View camera/draft/output edits belong to the caller's visual intent stamp.
pub fn interactive_scene_identity(&self) -> SceneIdentity {
SceneIdentity {
uid: self.uid,
revision: self.revision,
document: Arc::downgrade(&self.doc),
model: Arc::downgrade(&self.model.state()),
pending_moves: self.pending_moves.clone(),
active_sketch: self.active_sketch,
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
visibility: self.visibility.clone(),
}
}
}
impl Session {
/// Existing prepared world only, for Interactive reflection-binding proof.
/// None is not a capture-availability decision: SceneIdentity remains valid
/// for cold assemblies and the capture worker prepares their real world.
pub fn interactive_cached_world_state(&self) -> Option<Arc<solidcraft_doc::ModelState>> {
self.cached_world_state()
}
}
// Exact identity checks must not prepare cold geometry on the window thread.
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod scene_identity_tests {
use super::*;
use serde_json::json;
#[test]
fn cold_assembly_stamp_neither_places_nor_meshes() {
let mut session = Session::default();
session.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Source"})).unwrap();
// A root body plus an empty placed component is a valid cold assembly.
let doc = session.doc_mut();
doc.components.push(solidcraft_doc::Component { id: 100, name: "Empty component".into(), ..Default::default() });
doc.occurrences.push(solidcraft_doc::Occurrence {
id: 101,
component: 100,
parent: 0,
name: "Empty placement".into(),
transform: solidcraft_doc::IDENTITY,
grounded: false,
});
assert!(session.world_cache.lock().unwrap().is_none());
let state = session.model.state();
assert!(state.body("Source").unwrap().cached_mesh().is_none());
let stamp = session.interactive_scene_identity();
for _ in 0..10 {
assert!(stamp.is_current(&session));
}
assert!(session.world_cache.lock().unwrap().is_none());
assert!(state.body("Source").unwrap().cached_mesh().is_none());
}
#[test]
fn exact_context_and_visibility_changes_invalidate_without_revision() {
let mut session = Session::default();
let revision = session.revision;
let stamp = session.interactive_scene_identity();
session.pending_moves.insert(7, solidcraft_doc::IDENTITY);
assert!(!stamp.is_current(&session));
session.pending_moves.clear();
assert!(stamp.is_current(&session));
session.active_component = 7;
assert!(!stamp.is_current(&session));
session.active_component = 0;
session.active_occurrence_path = Some(vec![7, 8]);
assert!(!stamp.is_current(&session));
session.active_occurrence_path = None;
session.active_sketch = Some(9);
assert!(!stamp.is_current(&session));
session.active_sketch = None;
session.visibility.hidden_occurrences.push(vec![7, 8]);
assert!(!stamp.is_current(&session));
session.visibility.hidden_occurrences.clear();
session.visibility.hidden_folders.push("c0/bodies".into());
assert!(!stamp.is_current(&session));
assert_eq!(session.revision, revision);
}
#[test]
fn stamp_has_no_strong_document_or_model_retention() {
let session = Session::default();
let model = session.model.state();
let doc_count = Arc::strong_count(&session.doc);
let model_count = Arc::strong_count(&model);
let doc = Arc::downgrade(&session.doc);
let weak_model = Arc::downgrade(&model);
let stamp = session.interactive_scene_identity();
assert_eq!(Arc::strong_count(&session.doc), doc_count);
assert_eq!(Arc::strong_count(&model), model_count);
assert!(stamp.is_current(&session));
drop(model);
drop(session);
assert!(doc.upgrade().is_none());
assert!(weak_model.upgrade().is_none());
// Retained Weak allocation identities cannot be recycled for a new object.
let mut replacement = Session::default();
replacement.uid = stamp.uid;
replacement.revision = stamp.revision;
assert!(!stamp.is_current(&replacement));
}
#[test]
fn same_document_and_revision_do_not_hide_a_replaced_evaluated_model() {
let mut session = Session::default();
let stamp = session.interactive_scene_identity();
session.model = Session::default().model;
assert!(!stamp.is_current(&session));
let refreshed = session.interactive_scene_identity();
assert!(refreshed.is_current(&session));
session.revision += 1;
assert!(!refreshed.is_current(&session));
}
}
@@ -0,0 +1,688 @@
//! Per-document, transient render ownership. Captured scenes remain immutable when CAD edits
//! continue; callers get the captured revision and must never present it as a newer scene.
use crate::render_interactive::{Controller, InteractiveRender, with_controller};
use solidcraft_render::{
ExecutorDescriptor,
job::{CpuHost, ErrorCode, HostKind, Integrator, JobError, JobHandle, JobId, JobStatus, Phase, RenderFrame},
};
use std::collections::VecDeque;
use std::sync::{
Arc, Weak,
atomic::{AtomicU64, Ordering},
};
static NEXT_OWNER: AtomicU64 = AtomicU64::new(1);
/// The native CPU worker has two explicit execution methods. This pure metadata
/// lookup neither creates a host nor enables raytracing in cooperative/feature-off builds.
/// A stopped native host keeps its descriptor, marked unavailable by the host.
pub(crate) fn cpu_raytrace_descriptor(host: Option<&CpuHost>) -> Option<ExecutorDescriptor> {
let host = host.filter(|host| host.kind() == HostKind::NativeWorker)?;
host.descriptor_for(Integrator::Raytrace).ok()
}
fn owner() -> u64 {
let mut n = NEXT_OWNER.load(Ordering::Relaxed);
loop {
let Some(next) = n.checked_add(1) else { return 0 };
match NEXT_OWNER.compare_exchange_weak(n, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return n,
Err(current) => n = current,
}
}
}
pub(crate) struct Execution {
pub executor: solidcraft_render::ExecutorId,
pub options: solidcraft_render::job::JobOptions,
pub environment: Option<solidcraft_render::lighting::environment_input::EnvironmentInput>,
}
pub(crate) struct Jobs {
owner: u64,
serial: u64,
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>,
/// One explicitly displayed completed result, independent of a newer completion.
/// The existing frame Arc keeps its original host reservation accounted for.
pinned: Option<JobHandle>,
/// Preview lifetime only; it never joins final live/history/export ownership.
interactive: Weak<Controller>,
}
impl Default for Jobs {
fn default() -> Self {
Self {
owner: owner(),
serial: 0,
generation: 0,
host: None,
descriptor: None,
additional: Vec::new(),
live: Vec::new(),
history: VecDeque::new(),
last_good: None,
pinned: None,
interactive: Weak::new(),
}
}
}
impl Drop for Jobs {
fn drop(&mut self) {
let _ = self.revoke_interactive();
for job in &self.live {
let _ = job.cancel();
}
}
}
impl Jobs {
pub(crate) fn is_interactive_current(&self, controller: &Weak<Controller>) -> bool {
Weak::ptr_eq(&self.interactive, controller)
}
fn revoke_interactive(&mut self) -> Result<(), JobError> {
let previous = std::mem::take(&mut self.interactive);
match previous.upgrade() {
Some(controller) => with_controller(&controller, |state| state.disable()),
None => Ok(()),
}
}
fn suspend_interactive(&self) -> Result<(), JobError> {
match self.interactive.upgrade() {
Some(controller) => with_controller(&controller, |state| state.set_final_active(true, std::time::Duration::ZERO)),
None => Ok(()),
}
}
fn final_active(&self) -> Result<bool, JobError> {
for job in &self.live {
if !job.status()?.progress.phase.terminal() {
return Ok(true);
}
}
Ok(false)
}
fn create_interactive(&mut self, document_uid: u64) -> Result<InteractiveRender, JobError> {
let host = self.host.as_ref().ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no CPU image host is installed"))?;
if !host.is_available() || host.kind() != HostKind::NativeWorker {
return Err(JobError::new(ErrorCode::HostUnavailable, "Interactive CPU requires an available native CPU host"));
}
host.validate_integrator(Integrator::Raytrace)?;
let final_active = self.final_active()?;
if document_uid == 0 || self.owner == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "render session identity exhausted"));
}
// Use the same allocator as final owners; never reuse the document uid or
// scratch owner as a preview namespace. Zero reports allocator exhaustion.
let preview_session = owner();
if preview_session == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "Interactive owner identity exhausted"));
}
let next = InteractiveRender::new(document_uid, preview_session, final_active)?;
self.revoke_interactive()?;
self.interactive = next.weak();
Ok(next)
}
pub(crate) fn host_for(&self, executor: &solidcraft_render::ExecutorId) -> Option<&CpuHost> {
if executor.as_str() == "cpu-raytrace" {
return self
.host
.as_ref()
.filter(|host| host.descriptor_for(solidcraft_render::job::Integrator::Raytrace).is_ok_and(|d| &d.id == executor));
}
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
}
pub(crate) fn collect(&mut self) -> Result<(), JobError> {
let mut i = 0;
while i < self.live.len() {
let status = self.live.get(i).ok_or_else(|| JobError::new(ErrorCode::Internal, "missing live job"))?.status()?;
if !status.progress.phase.terminal() {
i += 1;
continue;
}
let handle = self.live.remove(i);
if status.progress.phase == Phase::Completed && self.last_good.as_ref().is_none_or(|old| old.id().serial < handle.id().serial) {
self.last_good = Some(handle);
}
self.history.push_back(status);
while self.history.len() > 4 {
self.history.pop_front();
}
}
Ok(())
}
pub(crate) fn start_with_environment(
&mut self,
revision: u64,
scene: solidcraft_render::ProductScene,
request: solidcraft_render::job::ImageRequest,
replace: bool,
execution: Execution,
) -> Result<JobStatus, JobError> {
let Execution { executor, options, environment } = execution;
self.collect()?;
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()?;
// Replaced handles are deliberately abandoned: keep a cancellation tombstone,
// not a permanently "cancelling" snapshot. The token already prevents any
// publication; the host can finish releasing its buffers asynchronously.
if !status.progress.phase.terminal() {
status.progress.phase = Phase::Cancelled;
}
self.history.push_back(status);
}
while self.history.len() > 4 {
self.history.pop_front();
}
}
if self.live.len() >= 2 {
return Err(JobError::new(ErrorCode::QueueFull, "this document already has an active and pending render"));
}
if self.owner == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "render session identity exhausted"));
}
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"))?;
self.suspend_interactive()?;
let handle = host.submit_with_environment_and_options(
JobId { session: self.owner, serial, generation },
revision,
scene,
request,
options,
environment,
)?;
let status = handle.status()?;
self.serial = serial;
self.generation = generation;
self.live.push(handle);
Ok(status)
}
pub(crate) fn regrade(
&mut self,
source_id: JobId,
presentation: solidcraft_render::job::presentation::Presentation,
) -> Result<JobStatus, JobError> {
presentation.validate()?;
self.collect()?;
let source = self.handle(source_id)?;
let frame = source.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "regrade requires a completed frame"))?;
let master = frame.master_source()?;
// Prefer the source pool so its retained source and derivative work share
// one admission authority. Regrading itself never invokes its executor.
let host = self
.host_for(&master.info.executor)
.or(self.host.as_ref())
.ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no image host is installed for master presentation"))?
.clone();
if self.live.len() >= 2 {
return Err(JobError::new(ErrorCode::QueueFull, "this document already has an active and pending render"));
}
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"))?;
if self.owner == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "render session identity exhausted"));
}
self.suspend_interactive()?;
let handle = host.submit_regrade(JobId { session: self.owner, serial, generation }, &source, presentation)?;
let status = handle.status()?;
self.serial = serial;
self.generation = generation;
self.live.push(handle);
Ok(status)
}
fn check_owner(&self, id: JobId) -> Result<(), JobError> {
if id.session != self.owner {
return Err(JobError::new(ErrorCode::WrongOwner, "render job belongs to another document session"));
}
Ok(())
}
pub(crate) fn handle(&self, id: JobId) -> Result<JobHandle, JobError> {
self.check_owner(id)?;
self.live
.iter()
.chain(self.last_good.iter())
.chain(self.pinned.iter())
.find(|j| j.id() == id)
.cloned()
.ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
}
pub(crate) fn status(&mut self, id: JobId) -> Result<JobStatus, JobError> {
self.check_owner(id)?;
self.collect()?;
if let Ok(handle) = self.handle(id) {
return handle.status();
}
self.history.iter().find(|s| s.job == id).cloned().ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
}
pub(crate) fn pin(&mut self, id: Option<JobId>) -> Result<Option<solidcraft_render::job::FrameInfo>, JobError> {
let Some(id) = id else {
self.pinned = None;
return Ok(None);
};
let handle = self.handle(id)?;
let frame = handle.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "only a completed result can be pinned"))?;
let info = frame.info.clone();
self.pinned = Some(handle);
Ok(Some(info))
}
pub(crate) fn frame(&mut self, id: JobId) -> Result<Arc<RenderFrame>, JobError> {
self.collect()?;
self.handle(id)?.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "render has no completed image"))
}
}
impl crate::Session {
/// Explicit activation support. The caller owns one app-wide capture worker.
/// This registers a lifetime only; it never captures or starts an image.
pub fn interactive_render(&mut self) -> crate::Result<InteractiveRender> {
self.render_jobs.create_interactive(self.uid).map_err(job_error)
}
/// Bounded final-job metadata for current/background document scheduling.
/// Retained completed images do not block Interactive; unknown status fails.
pub fn render_final_active(&self) -> crate::Result<bool> {
self.render_jobs.final_active().map_err(job_error)
}
/// A host is installed by the application, never by document data or a command.
pub fn set_render_job_host(&mut self, host: Option<CpuHost>) -> crate::Result<()> {
if self.render_jobs.host.as_ref().zip(host.as_ref()).is_some_and(|(a, b)| a.same_host(b)) {
return Ok(());
}
let descriptor = host.as_ref().map(CpuHost::descriptor).transpose().map_err(job_error)?;
if descriptor.as_ref().is_some_and(|d| d.id.as_str() == "cpu-raytrace") {
return Err(crate::EngineError::Other("cpu-raytrace is reserved for the native CPU raytrace integrator".into()));
}
let previous_raytrace = cpu_raytrace_descriptor(self.render_jobs.host.as_ref());
let mut backend = self.render_backend();
backend.executors.retain(|e| {
self.render_jobs.descriptor.as_ref().is_none_or(|old| old.id != e.id) && previous_raytrace.as_ref().is_none_or(|old| old.id != e.id)
});
if let Some(d) = &descriptor {
backend.executors.push(d.clone());
}
if let Some(raytrace) = cpu_raytrace_descriptor(host.as_ref()) {
backend.executors.push(raytrace);
}
solidcraft_render::resolve_request(
&[backend],
&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()))?;
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 descriptor.id.as_str() == "cpu-raytrace" {
return Err(crate::EngineError::Other("cpu-raytrace is reserved for the native CPU raytrace integrator".into()));
}
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()
}
/// Explicit native CLI waiting is separate from non-blocking status commands.
pub fn render_job_handle(&self, id: JobId) -> crate::Result<JobHandle> {
self.render_jobs.handle(id).map_err(job_error)
}
}
pub(crate) fn job_error(error: JobError) -> crate::EngineError {
crate::EngineError::Other(error.to_string())
}
#[cfg(test)]
mod displayed_result_tests {
use super::*;
use serde_json::json;
fn complete(s: &mut crate::Session, color: [u8; 3]) -> JobId {
let reply = s.execute("render.start", &json!({"settings":{"width":8,"height":8,"background":color}})).unwrap();
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
let host = s.render_job_host().unwrap().clone();
for _ in 0..10000 {
host.pump(16384).unwrap();
if s.render_job_handle(id).unwrap().status().unwrap().progress.phase.terminal() {
return id;
}
}
panic!("bounded result fixture did not finish")
}
#[test]
fn explicit_display_pin_preserves_the_old_exact_frame_without_unbounded_history() {
let mut s = crate::Session::default();
let host = CpuHost::cooperative();
s.set_render_job_host(Some(host.clone())).unwrap();
let before = s.doc.to_json();
let undo = s.undo.len();
let old = complete(&mut s, [10, 20, 30]);
let bytes = s.render_job_handle(old).unwrap().frame().unwrap().unwrap().png().to_vec();
s.execute("render.result.pin", &json!({"job":old})).unwrap();
for color in [[40, 50, 60], [70, 80, 90], [100, 110, 120], [130, 140, 150], [160, 170, 180]] {
let new = complete(&mut s, color);
s.execute("render.status", &json!({"job":new})).unwrap();
assert_eq!(s.render_job_handle(old).unwrap().frame().unwrap().unwrap().png(), bytes);
}
let retained = host.admitted_bytes();
let foreign = JobId { session: old.session + 1, ..old };
assert!(s.execute("render.result.pin", &json!({"job":foreign})).is_err());
assert_eq!(host.admitted_bytes(), retained);
assert!(s.execute("render.result.pin", &json!({})).is_err());
s.execute("render.result.pin", &json!({"job":null})).unwrap();
assert!(s.render_job_handle(old).is_err());
assert!(host.admitted_bytes() < retained);
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), undo);
let reply = s.execute("render.start", &json!({"settings":{"width":8,"height":8}})).unwrap();
assert!(s.execute("render.result.pin", &json!({"job":reply["job"]})).is_err(), "a pending result is never exportable");
}
}
// Original context ownership and real capture integration cases.
#[cfg(all(test, feature = "raytrace-cpu", not(target_arch = "wasm32")))]
mod interactive_context_tests {
use super::*;
use crate::render_interactive::{InteractiveRender, Snapshot, adopt_created};
use serde_json::{Value, json};
use solidcraft_render::job::interactive::{State, VisualRevision};
use std::time::Duration;
fn native() -> (crate::Session, CpuHost) {
let host = CpuHost::for_platform().unwrap();
let mut session = crate::Session::default();
session.set_render_job_host(Some(host.clone())).unwrap();
(session, host)
}
fn wire(background: [u8; 3]) -> Value {
json!({"integrator":"raytrace","settings":{"width":16,"height":16,"background":background},"raytrace":{"lights":[],"shadows":false}})
}
fn capture(context: &InteractiveRender, session: &crate::Session, request: &Value) -> Snapshot {
context.enable(VisualRevision { scene_generation: session.revision, visual_revision: 1 }, Duration::ZERO).unwrap();
let ticket = context.capture(Duration::from_millis(200)).unwrap().unwrap();
context.snapshot(session, ticket, request).unwrap()
}
fn terminal(job: &JobHandle) -> Phase {
// Completion watchdog follows existing native job tests; no speed assertion.
job.wait_timeout(Duration::from_secs(60)).unwrap().progress.phase
}
fn created(session: &crate::Session, ticket: solidcraft_render::job::interactive::CaptureTicket, request: &Value) -> JobHandle {
let p = crate::cmd::prepare_interactive(session, request).unwrap();
session
.render_job_host()
.unwrap()
.submit_with_environment_and_options(ticket.job_id(), ticket.revision().scene_generation, p.scene, p.request, p.options, p.environment)
.unwrap()
}
#[test]
fn registration_is_explicit_and_preview_frames_never_join_final_registry() {
let (mut session, _) = native();
let document = session.doc.clone();
let history = (session.revision, session.undo.len(), session.redo.len());
let context = session.interactive_render().unwrap();
assert_eq!(context.state().unwrap(), State::Off);
assert!(context.is_current(&session));
assert!(context.capture(Duration::from_secs(1)).unwrap().is_none());
let snapshot = capture(&context, &session, &wire([31, 67, 101]));
let ticket = snapshot.ticket();
assert_ne!(ticket.job_id().session, session.render_jobs.owner);
assert_eq!(snapshot.document_uid(), session.uid);
assert!(snapshot.run().unwrap());
let preview = context.current().unwrap().unwrap();
assert_eq!(terminal(&preview), Phase::Completed);
assert!(preview.frame().unwrap().is_some());
assert!(session.render_job_handle(preview.id()).is_err());
assert!(session.execute("render.status", &json!({"job":preview.id()})).is_err());
assert!(session.render_jobs.live.is_empty());
assert!(session.render_jobs.last_good.is_none());
assert!(session.render_jobs.pinned.is_none());
assert_eq!(history, (session.revision, session.undo.len(), session.redo.len()));
assert!(Arc::ptr_eq(&document, &session.doc));
}
#[test]
fn direct_final_start_preempts_only_interactive_and_finishes() {
let (mut session, _) = native();
let context = session.interactive_render().unwrap();
assert!(capture(&context, &session, &wire([11, 29, 47])).run().unwrap());
let old = context.current().unwrap().unwrap();
let reply = session.execute("render.start", &wire([83, 107, 139])).unwrap();
let final_id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
let final_job = session.render_job_handle(final_id).unwrap();
assert_ne!(final_id.session, old.id().session);
assert_eq!(context.state().unwrap(), State::Paused);
assert!(context.current().unwrap().is_none());
assert!(context.capture(Duration::from_secs(100)).unwrap().is_none());
assert_eq!(terminal(&final_job), Phase::Completed);
assert!(matches!(terminal(&old), Phase::Cancelled | Phase::Completed));
assert!(!session.render_final_active().unwrap());
let digest = final_job.frame().unwrap().unwrap().info.png_sha256.clone();
context.disable().unwrap();
drop(context);
assert_eq!(session.render_job_handle(final_id).unwrap().frame().unwrap().unwrap().info.png_sha256, digest);
}
#[test]
fn replacement_context_revokes_old_capture_without_meshing() {
let (mut session, host) = native();
session.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Source"})).unwrap();
let world = session.world_state();
assert!(world.body("Source").unwrap().cached_mesh().is_none());
let old = session.interactive_render().unwrap();
let snapshot = capture(&old, &session, &wire([1, 2, 3]));
let old_owner = snapshot.ticket().job_id().session;
let fresh = session.interactive_render().unwrap();
assert!(!old.is_current(&session));
assert_eq!(old.state().unwrap(), State::Off);
assert_eq!(fresh.state().unwrap(), State::Off);
assert!(!snapshot.run().unwrap());
assert!(world.body("Source").unwrap().cached_mesh().is_none());
assert_eq!(host.admitted_bytes(), 0);
let next = capture(&fresh, &session, &wire([4, 5, 6]));
assert_ne!(next.ticket().job_id().session, old_owner);
}
#[test]
fn host_a_b_a_cannot_revive_old_registration_in_the_same_document() {
let (mut session, host_a) = native();
let uid = session.uid;
let old = session.interactive_render().unwrap();
let snapshot = capture(&old, &session, &wire([1, 2, 3]));
let host_b = CpuHost::for_platform().unwrap();
session.set_render_job_host(Some(host_b)).unwrap();
assert_eq!(session.uid, uid);
assert!(!old.is_current(&session));
assert_eq!(old.state().unwrap(), State::Off);
assert!(!snapshot.run().unwrap());
session.set_render_job_host(Some(host_a.clone())).unwrap();
assert!(!old.is_current(&session));
old.enable(VisualRevision { scene_generation: session.revision, visual_revision: 2 }, Duration::ZERO).unwrap();
let stale = old.capture(Duration::from_millis(200)).unwrap().unwrap();
assert!(old.snapshot(&session, stale, &wire([7, 8, 9])).is_err());
assert_eq!(host_a.admitted_bytes(), 0);
assert!(session.interactive_render().unwrap().is_current(&session));
}
#[test]
fn new_design_and_equal_revision_foreign_session_refuse_old_tickets() {
let (mut session, host) = native();
let context = session.interactive_render().unwrap();
let snapshot = capture(&context, &session, &wire([1, 2, 3]));
let ticket = snapshot.ticket();
let uid = session.uid;
let mut foreign = crate::Session::new((*session.doc).clone());
foreign.revision = session.revision;
foreign.set_render_job_host(Some(host.clone())).unwrap();
assert_ne!(foreign.uid, uid);
assert!(!context.is_current(&foreign));
assert!(context.snapshot(&foreign, ticket, &wire([4, 5, 6])).is_err());
session.execute("file.new", &json!({})).unwrap();
assert_ne!(session.uid, uid);
assert!(!context.is_current(&session));
assert_eq!(context.state().unwrap(), State::Off);
assert!(!snapshot.run().unwrap());
assert_eq!(host.admitted_bytes(), 0);
}
#[test]
fn scratch_drop_does_not_revoke_source_and_exhaustion_is_local() {
let (mut session, _) = native();
let context = session.interactive_render().unwrap();
drop(session.scratch());
assert!(context.is_current(&session));
assert_eq!(context.state().unwrap(), State::Off);
assert!(InteractiveRender::new(session.uid, 0, false).is_err());
let original_owner = session.render_jobs.owner;
session.render_jobs.owner = 0;
assert!(session.interactive_render().is_err());
assert!(context.is_current(&session));
session.render_jobs.owner = original_owner;
}
#[test]
fn failed_adoption_with_poisoned_controller_cancels_unpumped_cooperative_record() {
let (mut session, _) = native();
let context = session.interactive_render().unwrap();
let snapshot = capture(&context, &session, &wire([1, 2, 3]));
let ticket = snapshot.ticket();
let p = crate::cmd::prepare_interactive(&session, &wire([1, 2, 3])).unwrap();
let mut request = p.request;
request.integrator = Integrator::Raster;
request.raytrace = None;
// A real cooperative queue cannot complete without an explicit pump.
// Adoption checks owner, then the poisoned lifecycle before integrator.
let cooperative = CpuHost::cooperative();
let job = cooperative.submit(ticket.job_id(), ticket.revision().scene_generation, p.scene, request).unwrap();
assert_eq!(job.status().unwrap().progress.phase, Phase::Queued);
let controller = session.render_jobs.interactive.upgrade().unwrap();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = controller.lock().unwrap();
panic!("original poison fixture");
}));
assert!(adopt_created(&controller, ticket, job.clone()).is_err());
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
assert!(context.current().is_err(), "a poisoned lifecycle is not resumed");
// No pump, sleeps, synthetic executor or large scene is needed. Removing
// Unadopted::drop cancellation leaves Queued and fails this assertion.
// Only controller poison is tested; record.data poison is best-effort.
}
#[test]
fn rejected_fresh_equal_id_record_preserves_retained_completed_frame() {
let (mut session, _) = native();
let context = session.interactive_render().unwrap();
let snapshot = capture(&context, &session, &wire([17, 31, 59]));
let ticket = snapshot.ticket();
assert!(snapshot.run().unwrap());
let accepted = context.current().unwrap().unwrap();
assert_eq!(terminal(&accepted), Phase::Completed);
let frame = accepted.frame().unwrap().unwrap();
let digest = frame.info.png_sha256.clone();
let duplicate = created(&session, ticket, &wire([83, 107, 139]));
assert_eq!(duplicate.id(), accepted.id());
let controller = session.render_jobs.interactive.upgrade().unwrap();
assert!(!adopt_created(&controller, ticket, duplicate.clone()).unwrap());
let phase = duplicate.status().unwrap().progress.phase;
assert!(phase.terminal() || phase == Phase::Cancelling);
assert!(matches!(terminal(&duplicate), Phase::Cancelled | Phase::Completed));
let retained = context.current().unwrap().unwrap().frame().unwrap().unwrap();
assert!(Arc::ptr_eq(&retained, &frame));
assert_eq!(retained.info.png_sha256, digest);
// This native fixture proves accepted-record retention only. Completed
// duplicate work does not prove a cancellation request; core queued
// distinct-record acceptance covers that separate invariant.
assert_eq!(accepted.status().unwrap().request.background, [17, 31, 59]);
}
#[test]
fn dropping_context_before_worker_start_keeps_the_source_mesh_cold() {
let (mut session, host) = native();
session.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Source"})).unwrap();
let world = session.world_state();
assert!(world.body("Source").unwrap().cached_mesh().is_none());
let context = session.interactive_render().unwrap();
let snapshot = capture(&context, &session, &wire([1, 2, 3]));
drop(context);
assert!(!snapshot.run().unwrap());
assert!(world.body("Source").unwrap().cached_mesh().is_none());
assert_eq!(host.admitted_bytes(), 0);
}
}
@@ -0,0 +1,28 @@
//! Typed Render workspace UI requests; headless calls only echo validated inputs.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CheckReport {
/// Report text, bounded to 65536 UTF-8 bytes by the command handler.
#[schemars(length(min = 1, max = 65536))]
pub text: String,
}
/// Explicit provisional-preview action; no implicit toggle or default activation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Action {
On,
Off,
Pause,
Resume,
}
/// A validated UI action echoed unchanged by the headless command handler.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct InteractiveParams {
pub action: Action,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
//! Transient CPU Interactive scheduling. The UI adapter owns visual revisions;
//! this controller owns cancellation, not document settings or final-image export.
use std::time::Duration;
use super::{CpuProgressiveOptions, ErrorCode, ImageRequest, Integrator, JobError, JobHandle, JobId, Phase, Result};
const SETTLE: Duration = Duration::from_millis(200);
const MAX_EDGE: u32 = 1024;
/// Prepare a separate low-resolution request. Never edits the final request,
/// changes renderer, removes optics/lights, or promises geometry admission.
pub fn cpu_request(final_request: &ImageRequest) -> Result<ImageRequest> {
if final_request.integrator != Integrator::Raytrace || final_request.transparent_background {
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive CPU requires opaque ray tracing"));
}
let (width, height) = (final_request.width, final_request.height);
if width == 0 || height == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive output dimensions must be positive"));
}
if let Some(options) = &final_request.raytrace {
options.validate()?;
}
let edge = width.max(height);
let fit = |side: u32| {
if edge <= MAX_EDGE { side } else { ((u64::from(side) * u64::from(MAX_EDGE) + u64::from(edge) / 2) / u64::from(edge)).max(1) as u32 }
};
let mut request = final_request.clone();
request.width = fit(width);
request.height = fit(height);
let options = request.raytrace.get_or_insert_default();
options.primary_samples = 1;
// These are preview sampling choices only. Incompatible transport remains an
// explicit error, rather than silently removing the user's requested effects.
options.progressive = Some(CpuProgressiveOptions::default());
if let Some(reflections) = &mut options.reflections {
reflections.samples = 1;
}
options.validate()?;
options.budget.validate_image(request.width, request.height, options.progressive)?;
Ok(request)
}
/// Caller increments visual_revision for every camera/draft/appearance/light/HDR
/// or output edit, even if scene_generation did not change. Never use only a CAD
/// revision, a body name, or the selected setup name as the visual identity.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VisualRevision {
pub scene_generation: u64,
pub visual_revision: u64,
}
/// Issued only after the edit group settles. Private fields prevent fabricating
/// a ticket for a newer request from a delayed capture.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CaptureTicket {
session: u64,
serial: u64,
revision: VisualRevision,
}
impl CaptureTicket {
pub fn revision(self) -> VisualRevision {
self.revision
}
/// Submit this identity to the preview host, outside the final-job registry.
pub fn job_id(self) -> JobId {
JobId { session: self.session, serial: self.serial, generation: self.serial }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Off,
Updating,
Refining,
Paused,
TargetReached,
Error,
}
/// One controller per document session. No serialization, clocks, threads, scene
/// extraction or new renderer here. Supply monotonic elapsed time from the host;
/// this keeps both browser integration and deterministic event tests possible.
pub struct Interactive {
session: u64,
serial: u64,
revision: Option<VisualRevision>,
changed_at: Duration,
enabled: bool,
paused: bool,
final_active: bool,
ticket: Option<CaptureTicket>,
active: Option<JobHandle>,
error: Option<JobError>,
}
impl Interactive {
pub fn new(session: u64) -> Self {
Self {
session,
serial: 0,
revision: None,
changed_at: Duration::ZERO,
enabled: false,
paused: false,
final_active: false,
ticket: None,
active: None,
error: None,
}
}
fn cancel(&mut self) -> Result<()> {
// Invalidate first, including when a poisoned host refuses cancellation.
self.ticket = None;
if let Some(job) = self.active.take() {
job.cancel()?;
}
Ok(())
}
/// Explicit user activation. Merely entering Render must not call this.
pub fn enable(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
self.cancel()?;
self.enabled = true;
self.paused = false;
self.error = None;
self.revision = Some(revision);
self.changed_at = now;
Ok(())
}
/// Workspace/document departure uses disable; manually started final jobs
/// are deliberately outside this controller and are never cancelled here.
pub fn disable(&mut self) -> Result<()> {
self.enabled = false;
self.paused = false;
self.error = None;
self.cancel()
}
pub fn edit(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
if self.revision != Some(revision) {
self.revision = Some(revision);
self.changed_at = now;
self.error = None;
self.cancel()?;
}
Ok(())
}
/// A view may retain an identified immutable PNG before pausing, but must
/// label it paused/stale. It is never a current or exportable final image.
pub fn pause(&mut self) -> Result<()> {
self.paused = true;
self.cancel()
}
pub fn resume(&mut self, now: Duration) -> Result<()> {
self.cancel()?;
self.paused = false;
self.error = None;
self.changed_at = now;
Ok(())
}
pub fn set_final_active(&mut self, active: bool, now: Duration) -> Result<()> {
if self.final_active != active {
self.final_active = active;
self.changed_at = now;
self.cancel()?;
}
Ok(())
}
/// At most one unacknowledged capture; no queue of obsolete requests.
pub fn capture(&mut self, now: Duration) -> Result<Option<CaptureTicket>> {
if !self.enabled
|| self.paused
|| self.final_active
|| self.ticket.is_some()
|| self.error.is_some()
|| now.checked_sub(self.changed_at).is_none_or(|elapsed| elapsed < SETTLE)
{
return Ok(None);
}
let Some(revision) = self.revision else { return Ok(None) };
self.serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Interactive capture identity exhausted"))?;
let ticket = CaptureTicket { session: self.session, serial: self.serial, revision };
self.ticket = Some(ticket);
Ok(Some(ticket))
}
/// Recheck before expensive capture/admission, including after a worker delay.
/// A ticket is not permission to prepare an obsolete or already adopted job.
pub fn accepts_capture(&self, ticket: CaptureTicket) -> bool {
self.enabled && !self.paused && !self.final_active && self.error.is_none() && self.ticket == Some(ticket) && self.active.is_none()
}
/// Attach an admitted preview-only job after immutable capture. Delayed jobs
/// are cancelled, including a previous ticket for the same visual revision.
/// Use a separate engine job session/registry from final-image export.
pub fn attach(&mut self, ticket: CaptureTicket, job: JobHandle) -> Result<bool> {
// A misrouted callback must never cancel another document's work or a
// manual final job. Only our dedicated preview session is ours to stop.
if job.id().session != self.session {
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job belongs to another session"));
return Ok(false);
}
if self.ticket != Some(ticket) || ticket.session != self.session {
// Protect an admitted current capture even before acknowledgement.
// Once accepted, only the same record (not merely its JobId) is ours.
let current = match &self.active {
Some(active) => active.same_record(&job),
None => self.ticket.is_some_and(|ticket| ticket.job_id() == job.id()),
};
if !current {
job.cancel()?;
}
return Ok(false);
}
let status = job.status()?;
if status.job != ticket.job_id() || status.scene_generation != ticket.revision.scene_generation {
job.cancel()?;
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job capture identity mismatch"));
return Ok(false);
}
if status.request.integrator != Integrator::Raytrace || status.request.raytrace.as_ref().is_none_or(|options| options.progressive.is_none()) {
job.cancel()?;
self.capture_failed(ticket, JobError::new(ErrorCode::InvalidRequest, "Interactive requires a CPU progressive job"));
return Ok(false);
}
// A ticket is acknowledged exactly once, even if the same capture is
// delivered twice while an earlier attached job is still running.
if self.active.is_some() {
if self.active.as_ref().is_none_or(|active| !active.same_record(&job)) {
job.cancel()?;
}
return Ok(false);
}
self.active = Some(job);
Ok(true)
}
pub fn capture_failed(&mut self, ticket: CaptureTicket, error: JobError) {
if self.ticket == Some(ticket) && self.active.is_none() {
self.ticket = None;
self.error = Some(error);
}
}
/// Access only the current capture. The adapter reads checkpoint/final bytes
/// through this handle, displaying both as Interactive/provisional content.
pub fn current(&self) -> Option<&JobHandle> {
self.active.as_ref()
}
pub fn error(&self) -> Option<&JobError> {
self.error.as_ref()
}
pub fn state(&self) -> Result<State> {
if !self.enabled {
return Ok(State::Off);
}
if self.paused || self.final_active {
return Ok(State::Paused);
}
if self.error.is_some() {
return Ok(State::Error);
}
if let Some(job) = &self.active {
return Ok(match job.status()?.progress.phase {
Phase::Completed => State::TargetReached,
Phase::Cancelled | Phase::Failed => State::Error,
_ => State::Refining,
});
}
Ok(State::Updating)
}
}
impl Drop for Interactive {
fn drop(&mut self) {
let _ = self.cancel();
}
}
@@ -0,0 +1,423 @@
//! Written help for the ribbon tooltips (#2758): two short sentences per command, shown under the
//! tool name when the pointer rests on its button. The names stay as they are; the help is a
//! separate message `help.<command id>` in the catalogs, so a tool without help keeps its plain
//! tooltip. Text only for now; pictures and animations are a later step. A new toolbar command
//! ships with its help: `every_toolbar_command_has_help_or_is_on_the_backlog` fails otherwise.
use crate::i18n;
/// The commands that have help text. Each one needs a `help.<id>` message in the catalogs.
pub const DOCUMENTED: &[&str] = &[
"appearance.assign",
"component.finish_edit",
"face.delete",
"material.assign",
"occurrence.move",
"parameters.change",
"pointcloud.insert",
"render.interactive",
"solid.align",
"solid.chamfer",
"solid.combine",
"solid.draft",
"solid.extrude",
"solid.fillet",
"solid.hole",
"solid.loft",
"solid.mirror",
"solid.move",
"solid.offset_face",
"solid.pattern.circular",
"solid.pattern.rectangular",
"solid.press_pull",
"solid.remove",
"solid.replace_face",
"solid.revolve",
"solid.scale",
"solid.shell",
"solid.split_body",
"solid.split_face",
"solid.sweep",
"timeline.compute_all",
"timeline.delete",
"timeline.play",
];
/// The help for `command` in the current language, when it has any.
pub fn help(command: &str) -> Option<String> {
if !DOCUMENTED.contains(&command) {
return None;
}
let key = format!("help.{command}");
let text = i18n::tr(&key);
(text != key).then(|| text.to_owned())
}
/// Show `tip` (name, shortcut or the reason the tool is disabled) as the tooltip of `response`,
/// with the help of `command` below it when there is one.
pub fn tooltip(response: egui::Response, command: &str, tip: String) -> egui::Response {
let help = help(command);
response.on_hover_ui(move |ui| {
ui.set_max_width(300.0);
ui.label(tip);
if let Some(help) = help {
ui.add_space(2.0);
ui.label(egui::RichText::new(help).weak());
}
})
}
/// The tooltip text of a panel drop-down row: the reason when the command is disabled (as before),
/// else its name and shortcut when it has help to show below, else nothing (no tooltip).
pub fn row_tip(command: &str, label: &str, shortcut: Option<&str>, disabled_reason: Option<&str>) -> Option<String> {
match disabled_reason {
Some(why) => Some(why.to_owned()),
None => help(command).map(|_| format!("{label}{}", shortcut.map(|k| format!(" ({k})")).unwrap_or_default())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn catalog(source: &str) -> std::collections::BTreeMap<String, String> {
serde_json::from_str(source).unwrap()
}
/// The `help.<command id>` messages; other `help.*` messages (About dialog texts) are not tool help.
fn help_keys(source: &str) -> BTreeSet<String> {
catalog(source)
.into_keys()
.filter(|k| k.starts_with("help.") && (k.starts_with("help.solid.") || DOCUMENTED.contains(&&k["help.".len()..])))
.collect()
}
/// Toolbar commands written before every tool needed help (#2758). The list only shrinks: write
/// the help for one of these and take it off; a new toolbar command never goes on it.
const BACKLOG: &[&str] = &[
"assembly.bom",
"canvas.decal",
"canvas.edit",
"canvas.insert",
"component.break_link",
"component.capture_position",
"component.create",
"component.from_bodies",
"component.insert_design",
"component.properties",
"component.revert_position",
"component.update_links",
"config.configure",
"config.preview",
"config.rules",
"config.table",
"construct.axis.edge",
"construct.axis.normal_to_face",
"construct.axis.through_cylinder",
"construct.axis.two_planes",
"construct.axis.two_points",
"construct.plane.along_path",
"construct.plane.at_angle",
"construct.plane.midplane",
"construct.plane.offset",
"construct.plane.perpendicular",
"construct.plane.tangent",
"construct.plane.three_points",
"construct.plane.two_edges",
"construct.point.along_path",
"construct.point.center",
"construct.point.edge_plane",
"construct.point.three_planes",
"construct.point.two_edges",
"construct.point.vertex",
"contact.check",
"contact.create",
"contact.disable_all",
"contact.edit",
"contact.enable_all",
"contact.enable_sets",
"contact.list",
"document.inspect",
"document.units",
"edit.checkpoint",
"edit.restore_checkpoint",
"explode.create",
"file.insert_mesh",
"file.insert_step",
"file.save_mesh",
"file.send_to_slicer",
"frame.member",
"inspect.accessibility",
"inspect.center_of_mass",
"inspect.colored_components",
"inspect.compare",
"inspect.continuity",
"inspect.curvature_comb",
"inspect.curvature_map",
"inspect.draft",
"inspect.environment_map",
"inspect.interference",
"inspect.isocurve",
"inspect.measure",
"inspect.minimum_radius",
"inspect.section",
"inspect.zebra",
"joint.as_built",
"joint.cam",
"joint.create",
"joint.drive",
"joint.gear",
"joint.limits",
"joint.motion_link",
"joint.origin",
"joint.rack_pinion",
"joint.reach",
"joint.rigid_group",
"joint.screw",
"library.apply",
"library.export",
"library.inspect",
"material.properties.validate",
"mesh.convert",
"motion.study",
"motion.trace",
"occurrence.ground",
"parameters.export",
"parameters.graph",
"parameters.import",
"parameters.move",
"parts.fastener",
"parts.insert",
"parts.library",
"parts.resize",
"plastic.assign_rule",
"plastic.boss",
"plastic.lip",
"plastic.manage_rules",
"plastic.rest",
"plastic.snap_fit",
"sheet.convert",
"sheet.export_bend_table",
"sheet.export_dxf",
"sheet.flange",
"sheet.flat_pattern",
"sheet.fold",
"sheet.hem",
"sheet.manage_rules",
"sheet.path_flange",
"sheet.perforate",
"sheet.refold",
"sheet.unfold",
"sketch.arc.center_point",
"sketch.arc.tangent",
"sketch.arc.three_point",
"sketch.arc3d",
"sketch.auto_constrain",
"sketch.auto_constrain_from_datum",
"sketch.blend_curve",
"sketch.break",
"sketch.cancel",
"sketch.centerline",
"sketch.chamfer.distance_angle",
"sketch.chamfer.equal_distance",
"sketch.chamfer.two_distance",
"sketch.circle.center",
"sketch.circle.three_point",
"sketch.circle.three_tangent",
"sketch.circle.two_point",
"sketch.circle.two_tangent",
"sketch.conic",
"sketch.constrainer",
"sketch.constraint.coincident",
"sketch.constraint.collinear",
"sketch.constraint.concentric",
"sketch.constraint.curvature",
"sketch.constraint.equal",
"sketch.constraint.fix",
"sketch.constraint.horizontal_vertical",
"sketch.constraint.midpoint",
"sketch.constraint.parallel",
"sketch.constraint.perpendicular",
"sketch.constraint.polygon",
"sketch.constraint.symmetry",
"sketch.constraint.tangent",
"sketch.construction",
"sketch.copy",
"sketch.create",
"sketch.cut",
"sketch.dimension",
"sketch.edit",
"sketch.ellipse",
"sketch.extend",
"sketch.fillet",
"sketch.finish",
"sketch.finish_auto_constrain",
"sketch.fit_curves_to_section",
"sketch.include_3d",
"sketch.infinite",
"sketch.insert_dxf",
"sketch.insert_svg",
"sketch.intersect",
"sketch.intersection_curve",
"sketch.isoparametric_curve",
"sketch.line",
"sketch.line.midpoint",
"sketch.line3d",
"sketch.mirror",
"sketch.move",
"sketch.offset",
"sketch.paste",
"sketch.pattern.circular",
"sketch.pattern.rectangular",
"sketch.point",
"sketch.point3d",
"sketch.polygon.circumscribed",
"sketch.polygon.edge",
"sketch.polygon.inscribed",
"sketch.project",
"sketch.project_to_surface",
"sketch.rectangle.center",
"sketch.rectangle.rounded",
"sketch.rectangle.three_point",
"sketch.rectangle.two_point",
"sketch.scale",
"sketch.slot.arc_center",
"sketch.slot.arc_three_point",
"sketch.slot.center_point",
"sketch.slot.center_to_center",
"sketch.slot.overall",
"sketch.spline.control_point",
"sketch.spline.control_point_5",
"sketch.spline.fit_point",
"sketch.spline3d",
"sketch.spun_profile",
"sketch.text",
"sketch.transform",
"sketch.trim",
"solid.boundary_fill",
"solid.bounding_solid",
"solid.box",
"solid.coil",
"solid.cone",
"solid.cylinder",
"solid.emboss",
"solid.pattern.path",
"solid.pipe",
"solid.rib",
"solid.sphere",
"solid.thread",
"solid.torus",
"solid.web",
"surface.blend",
"surface.boundary",
"surface.extend",
"surface.extrude",
"surface.fair",
"surface.loft",
"surface.match",
"surface.offset",
"surface.patch",
"surface.reverse_normal",
"surface.revolve",
"surface.ruled",
"surface.stitch",
"surface.sweep",
"surface.thicken",
"surface.trim",
"surface.unstitch",
"surface.untrim",
"timeline.dependents",
"view.apply",
"view.delete",
"view.fit",
"view.grid",
"view.ground_shadow",
"view.home",
"view.list",
"view.navigate",
"view.navigation_bar",
"view.rename",
"view.save",
"view.spin",
"view.tool",
"view.visual_style",
];
/// Every command the ribbon offers people, gathered the way the toolbar lays out its panels. A
/// planned command only says "not supported yet"; it gets its help when it is built.
fn toolbar_commands() -> BTreeSet<&'static str> {
let specs = solidcraft_engine::command_specs();
let mut ids = BTreeSet::new();
for section in crate::workspace::SECTIONS {
if let Some((commands, _)) = crate::workspace::layout(section.tab, section.panel, &specs) {
ids.extend(commands.iter().filter(|c| c.planned.is_none() && !crate::workspace::AGENT_ONLY.contains(&c.id)).map(|c| c.id));
}
}
ids
}
#[test]
fn every_toolbar_command_has_help_or_is_on_the_backlog() {
let toolbar = toolbar_commands();
let missing: Vec<_> = toolbar.iter().filter(|id| !DOCUMENTED.contains(id) && !BACKLOG.contains(id)).collect();
let stale: Vec<_> = BACKLOG.iter().filter(|id| !toolbar.contains(*id) || DOCUMENTED.contains(id)).collect();
assert!(
missing.is_empty() && stale.is_empty(),
"toolbar commands without help: {missing:?}. Add `help.<id>` (two short sentences: what the tool does, \
then when to use it) to a file in assets/locales/pending/ and the id to DOCUMENTED. Take these off the backlog \
(documented now, or not on the toolbar): {stale:?}"
);
assert!(BACKLOG.is_sorted());
}
#[test]
fn every_documented_id_is_a_real_command() {
for id in DOCUMENTED {
assert!(solidcraft_engine::find_command(id).is_some(), "{id} is not a command");
}
assert!(DOCUMENTED.is_sorted());
}
#[test]
fn every_help_message_is_for_a_documented_command() {
let english = help_keys(include_str!(concat!(env!("OUT_DIR"), "/locales/en.json")));
let wanted: BTreeSet<String> = DOCUMENTED.iter().map(|id| format!("help.{id}")).collect();
assert_eq!(english, wanted);
for key in &english {
let id = key.trim_start_matches("help.");
assert!(solidcraft_engine::find_command(id).is_some(), "{key} names no command");
}
}
#[test]
fn the_help_texts_are_written_in_every_complete_language() {
let english = catalog(include_str!(concat!(env!("OUT_DIR"), "/locales/en.json")));
for source in [include_str!(concat!(env!("OUT_DIR"), "/locales/ja.json")), include_str!(concat!(env!("OUT_DIR"), "/locales/zh.json"))] {
let other = catalog(source);
for (key, text) in english.iter().filter(|(k, _)| k.starts_with("help.")) {
let translated = other.get(key).unwrap_or_else(|| panic!("{key} missing"));
assert!(!translated.trim().is_empty());
// The messages carry no placeholders; if one is added, both sides must agree.
assert_eq!(text.matches('{').count(), translated.matches('{').count(), "{key}");
}
}
}
#[test]
fn a_documented_command_has_help_and_others_do_not() {
for id in DOCUMENTED {
assert!(help(id).is_some_and(|text| !text.is_empty()), "{id}");
}
assert!(help("solid.rib").is_none());
}
#[test]
fn a_drop_down_row_has_a_tip_only_for_a_reason_or_help() {
assert_eq!(row_tip("solid.shell", "Shell", Some("S"), None).as_deref(), Some("Shell (S)"));
assert_eq!(row_tip("solid.hole", "Hole", None, None).as_deref(), Some("Hole"));
assert_eq!(row_tip("solid.hole", "Hole", None, Some("Select a face")).as_deref(), Some("Select a face"));
assert_eq!(row_tip("solid.rib", "Rib", None, None), None);
assert_eq!(row_tip("solid.rib", "Rib", None, Some("No body")).as_deref(), Some("No body"));
}
}
@@ -0,0 +1,277 @@
//! Render shell composition; all hosting and chrome belong to the shared kit.
use crate::{
SolveApp,
theme::Tokens,
ui_kit::{self, Container, Edge},
};
use egui::{Rect, vec2};
pub(crate) fn reset_shell(app: &mut SolveApp) {
app.shell.workspace_panels.retain(|id, _| !id.starts_with("render:"));
app.shell.browser = None;
}
pub(crate) fn focused(app: &SolveApp) -> bool {
crate::render_workspace::active(app) && app.shell.render_focus
}
pub(crate) fn compact(app: &SolveApp, width: f32) -> bool {
width < 1100.0 || app.ui.layout == crate::shell::LayoutMode::Docked
}
pub(crate) fn focus_shortcut(app: &mut SolveApp, ui: &mut egui::Ui) {
if crate::render_workspace::active(app) && !ui.ctx().text_edit_focused() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab))
{
ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None));
app.start("view.render_focus");
}
}
pub(crate) fn canvas(app: &SolveApp, viewport: Rect) -> Rect {
crate::shell::content_rect(app, viewport)
}
/// The toolbar's shared floating Chrome card hosts both rows; there is no Render ribbon.
pub(crate) fn stage_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
use crate::render_workspace::Stage;
crate::scenario::publish_rect("render:stage_bar", rect);
ui.ctx().data_mut(|d| d.remove::<Rect>(egui::Id::new("sc_ribbon_search")));
let labels: Vec<_> = Stage::ALL.iter().map(|stage| app.tr(stage.label()).to_string()).collect();
let selected = Stage::ALL.iter().position(|s| *s == app.render_workspace.view.stage).unwrap_or(0);
let ready = [crate::render_workspace::job_ready(app, "render.start"), crate::render_workspace::job_ready(app, "render.cancel")];
let actions = [
ui_kit::StageAction {
icon: Some("play"),
label: app.tr_label("Render"),
enabled: ready[0].is_ok(),
primary: true,
reason: ready[0].as_ref().err().map(String::as_str),
},
ui_kit::StageAction {
icon: None,
label: app.tr_label("Cancel"),
enabled: ready[1].is_ok(),
primary: false,
reason: ready[1].as_ref().err().map(String::as_str),
},
];
let choice = ui_kit::StageBar::row(ui, rect, false, |ui| {
ui_kit::StageBar::header(ui, selected, &labels.iter().map(String::as_str).collect::<Vec<_>>(), &actions)
});
if let Some(choice) = choice {
for (stage, chip) in Stage::ALL.iter().zip(choice.chips).filter(|(_, r)| r.is_positive()) {
crate::scenario::publish_target(&format!("render:stage:{}", stage.key()), chip);
}
for (id, action) in ["render.start", "render.cancel"].iter().zip(choice.actions).filter(|(_, r)| r.is_positive()) {
crate::scenario::publish_target(&format!("render:stage:{id}"), action);
}
if let Some(stage) = choice.stage.and_then(|index| Stage::ALL.get(index)) {
let _ = app.run("view.render_stage", serde_json::json!({"stage":stage.key()}));
}
if let Some(id) = choice.action.and_then(|index| ["render.start", "render.cancel"].get(index)) {
app.start(id);
}
}
let split = (rect.top() + ui_kit::StageBar::HEADER_HEIGHT).min(rect.bottom());
crate::scenario::publish_rect("render:stage_header", Rect::from_min_max(rect.min, egui::pos2(rect.right(), split)));
crate::scenario::publish_rect("render:stage_divider", Rect::from_min_size(egui::pos2(rect.left(), split), vec2(rect.width(), 1.0)));
crate::scenario::publish_rect("render:stage_tools", Rect::from_min_max(egui::pos2(rect.left(), split), rect.max));
ui_kit::StageBar::row(ui, rect, true, |ui| stage_tools(app, ui));
}
fn stage_tools(app: &mut SolveApp, ui: &mut egui::Ui) {
use crate::render_workspace::Stage;
match app.render_workspace.view.stage {
Stage::Look => {
ui_kit::StageBar::caption(ui, app.tr("render.pick_label"));
let modes = ["body", "face", "component"];
let mut selected = modes.iter().position(|mode| *mode == current_pick_mode(app)).unwrap_or(0);
let pick = ui.add_enabled_ui(!crate::render_workspace::result_active(app), |ui| {
if ui_kit::segmented(
ui,
&mut selected,
&[app.tr("render.pick_body"), app.tr("render.pick_face"), app.tr("render.pick_component")],
ui_kit::Segments {
targets: &["render:pick:body", "render:pick:face", "render:pick:component"],
icons: &["body", "surface", "component"],
workspace_accent: true,
dense: true,
intrinsic: true,
..Default::default()
},
) && let Some(mode) = modes.get(selected)
{
let _ = app.run("select.filter", serde_json::json!({"mode":mode}));
}
});
crate::scenario::publish_target("render:pick", pick.response.rect);
pick.response.on_hover_text(app.tr("render.pick_help"));
ui_kit::StageBar::tool_gap(ui);
for (id, label, icon) in
[("appearance.paint", "render.paint_label", "appearance"), ("appearance.pick_up", "render.pick_up_label", "select")]
{
if let Some(command) = solidcraft_engine::find_command(id) {
let info = crate::render_workspace::interactive_command_info(app, command);
let response = ui_kit::compact_icon_label_button(ui, app.tr(label), icon, info.enabled);
crate::scenario::publish_target(&format!("render:stage:{id}"), response.rect);
let hint = crate::keymap::display_for(app, id)
.map_or_else(|| app.tr_label(command.label).to_string(), |key| format!("{} ({key})", app.tr_label(command.label)));
let response = response.on_hover_text(hint);
if response.clicked() {
app.start(id);
}
if let Some(reason) = info.disabled_reason {
response.on_disabled_hover_text(reason);
}
}
}
crate::render_workspace::assign::banner(app, ui);
ui_kit::StageBar::separator(ui);
let note = ui_kit::StageBar::note(ui, app.tr("render.stage_preview_note")).on_hover_text(app.tr("render.preview_lighting_help"));
crate::scenario::publish_rect("render:preview_note", note.rect);
if ui_kit::StageBar::icon_action(ui, "settings", app.tr("render.preview_image_settings_help")).clicked() {
app.start("render.settings.get");
}
}
Stage::Light => {
ui_kit::text(ui, app.tr("render.lights_fixed_to"), ui_kit::Text::Inline);
ui.add_enabled_ui(false, |ui| {
let mut selected = usize::MAX;
ui_kit::segmented(
ui,
&mut selected,
&[app.tr("render.fixed_camera"), app.tr("render.fixed_world")],
ui_kit::Segments { intrinsic: true, ..Default::default() },
);
})
.response
.on_disabled_hover_text(app.tr("render.light_anchor_pending"));
ui_kit::text(ui, app.tr("render.light_anchor_pending"), ui_kit::Text::Inline);
}
Stage::Frame => {
let mut projection = usize::from(app.cam.fov == 0.0);
if ui_kit::segmented(
ui,
&mut projection,
&[app.tr("render.perspective"), app.tr("render.stage_orthographic")],
ui_kit::Segments { intrinsic: true, ..Default::default() },
) && let Err(error) = crate::render_workspace::stage_projection(app, projection == 0)
{
app.set_status(error, true);
}
if let Some(command) = solidcraft_engine::find_command("render.settings.set") {
let info = crate::render_workspace::interactive_command_info(app, command);
let response = ui_kit::compact_icon_label_button(ui, app.tr("render.shots_save"), command.icon, info.enabled);
crate::scenario::publish_target("render:stage:save_shot", response.rect);
if response.clicked() {
app.start(command.id);
}
if let Some(reason) = info.disabled_reason {
response.on_disabled_hover_text(reason);
}
}
}
Stage::Output => crate::render_workspace::output_stage_tools(app, ui),
}
}
pub(crate) fn overlays(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::shell::register_panel(app, "render:focus:exit", None, None);
if focused(app) {
dock_chip(app, ui, "render:focus:exit", "render.exit_focus", "view.render_focus", serde_json::json!({"on":false}));
}
let Some(viewport) = app.viewport.rect else { return };
let free = canvas(app, ui.max_rect());
if !free.is_positive() {
return;
}
crate::shell::register_panel(app, "render:interactive-card", None, None);
if crate::render_workspace::job_busy(app) {
let pill = Container::navigation_card(ui.ctx(), egui::Id::new("sc_render_progress"), free.center_top(), egui::Align2::CENTER_TOP, |ui| {
crate::render_workspace::progress_pill(app, ui);
});
crate::shell::register_panel(app, "render:progress-pill", Some(pill.response.rect), None);
crate::scenario::publish_rect("render:progress-pill", pill.response.rect);
} else {
crate::shell::register_panel(app, "render:progress-pill", None, None);
crate::render_workspace::interactive::overlay(app, ui, free);
}
crate::render_workspace::environment_overlay(app, ui, viewport);
crate::render_workspace::assign::selection_bar(app, ui, free);
crate::render_workspace::assign::replacement_dialog(app, ui.ctx());
crate::navigation_bar_view::show(app, ui, viewport);
if !focused(app) {
shots_pill(app, ui, free);
}
crate::viewport::transient_messages(app, ui, viewport);
}
/// Shared kit chip at the Browser's remembered side, below the stage bar.
pub(crate) fn dock_chip(app: &mut SolveApp, ui: &mut egui::Ui, id: &'static str, label: &str, command: &str, params: serde_json::Value) {
let bounds = crate::shell::panel_bounds(app, ui.max_rect());
let t = Tokens::get();
if bounds.width() < t.control_height || bounds.height() < t.control_height {
return;
}
let left = app.preferences.render_browser_side == "left";
let position = if left { bounds.left_top() + vec2(t.space_8, 0.0) } else { bounds.right_top() - vec2(t.space_8, 0.0) };
let ctx = ui.ctx().clone();
let chip =
Container::navigation_card(&ctx, egui::Id::new(id), position, if left { egui::Align2::LEFT_TOP } else { egui::Align2::RIGHT_TOP }, |ui| {
let response = ui_kit::choice_chip(ui, id, app.tr(label), false);
crate::scenario::publish_target(id, response.rect);
if response.clicked() {
let _ = app.run(command, params);
}
});
crate::shell::register_panel(app, id, Some(chip.response.rect), None);
}
fn current_pick_mode(app: &SolveApp) -> &'static str {
if app.ui.pick_components {
"component"
} else if app.ui.pick_bodies {
"body"
} else {
"face"
}
}
fn shots_pill(app: &mut SolveApp, ui: &mut egui::Ui, free: Rect) {
crate::shell::register_panel(app, "render:shots:show", None, None);
if app.preferences.render_shots_strip {
return;
}
let ctx = ui.ctx().clone();
let pill = Container::navigation_card(
&ctx,
egui::Id::new("sc_render_shots_pill"),
free.left_bottom() + vec2(Tokens::get().space_8, -Tokens::get().space_8),
egui::Align2::LEFT_BOTTOM,
|ui| {
let response = ui_kit::strip_toggle(ui, false, crate::i18n::tr_label("Shots")).on_hover_text(crate::i18n::tr_label("Show Shots strip"));
crate::scenario::publish_target("render:shots:show", response.rect);
if response.clicked() {
let _ = app.run("view.shots_strip", serde_json::json!({"visible":true}));
}
},
);
crate::shell::register_panel(app, "render:shots:show", Some(pill.response.rect), None);
}
/// Existing saved-shot and job actions inside the shared floating bottom-strip host.
pub(crate) fn shots_strip(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::shell::register_panel(app, "render:shots_strip", None, None);
if focused(app) || !app.preferences.render_shots_strip {
return;
}
let available = ui.available_rect_before_wrap();
let content = ui_kit::Panel::bottom_strip(ui, "sc_render_shots_strip", available, Tokens::get().form_footer_height, |ui| {
crate::render_workspace::shots::contents(app, ui);
});
let rect = content.map(|c| c.response.rect);
crate::shell::register_panel(app, "render:shots_strip", rect, Some(Edge::Bottom));
if let Some(rect) = rect {
crate::scenario::publish_rect("render:shots_strip", rect);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,616 @@
//! Transient CPU image controls; engine commands own file capture and admission.
use serde_json::{Value, json};
use solidcraft_engine::render::job::{CpuBudget, RaytraceEnvironmentOptions, ReflectionBody, ReflectionMode};
use super::hdr;
use crate::{SolveApp, ui_kit};
pub(super) fn validate_progressive_image(app: &SolveApp) -> Result<(), String> {
if app.render_workspace.view.method == super::ImageMethod::CpuRaytrace {
let view = &app.render_workspace.view;
view.raytrace.budget.validate_image(view.draft.width, view.draft.height, view.raytrace.progressive).map_err(|error| error.to_string())?;
}
Ok(())
}
pub(super) fn image_limits(app: &SolveApp) -> solidcraft_engine::render::RenderLimits {
if app.render_workspace.view.method == super::ImageMethod::GpuRaytrace {
return solidcraft_engine::render::job::GPU_PATH_LIMITS;
}
if app.render_workspace.view.method != super::ImageMethod::CpuRaytrace {
return Default::default();
}
let options = &app.render_workspace.view.raytrace;
let mut limits = options.budget.image_limits(options.progressive.is_some());
if let Some(progressive) = options.progressive {
limits.max_pixels = limits.max_pixels.min(options.budget.limits().max_camera_samples / u64::from(progressive.samples_per_pixel.max(1)));
}
limits
}
fn budget_key(budget: CpuBudget) -> &'static str {
match budget {
CpuBudget::Compact => "render.children.compact",
CpuBudget::Studio => "render.children.studio",
CpuBudget::Workstation => "render.children.workstation",
}
}
fn budget_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
let budgets = CpuBudget::ALL;
let mut selected = budgets.iter().position(|b| *b == app.render_workspace.view.raytrace.budget).unwrap_or(0);
let labels = budgets.map(|b| app.tr(budget_key(b)).to_string());
let response = ui.scope(|ui| {
ui_kit::segmented(
ui,
&mut selected,
&labels.iter().map(String::as_str).collect::<Vec<_>>(),
ui_kit::Segments {
compact: true,
targets: &["render.cpu_budget_compact", "render.cpu_budget_studio", "render.cpu_budget_workstation"],
..Default::default()
},
)
});
crate::scenario::publish_rect("render:cpu-budget", response.response.rect);
if response.inner
&& let Some(budget) = budgets.get(selected)
{
app.render_workspace.view.raytrace.budget = *budget;
app.render_workspace.view.dirty = true;
}
let limits = app.render_workspace.view.raytrace.budget.limits();
let note = format!(
"{}: {:.2} GiB · {}: {} · {}: {} · {}: {}. {}",
app.tr("render.cpu_budget_memory"),
limits.max_job_bytes as f64 / 1_073_741_824.0,
app.tr("render.cpu_budget_triangles"),
limits.max_triangles,
app.tr("render.cpu_budget_bodies"),
limits.max_bodies,
app.tr("render.cpu_progressive_spp"),
limits.max_samples_per_pixel,
app.tr("render.cpu_budget_not_guarantee")
);
ui_kit::text(ui, &note, ui_kit::Text::Note("render.cpu_budget_help"));
}
fn set_progressive(app: &mut SolveApp, enabled: bool) {
let options = &mut app.render_workspace.view.raytrace;
if enabled {
options.progressive.get_or_insert_default();
options.primary_samples = 1;
} else {
options.progressive = None;
}
app.render_workspace.view.dirty = true;
}
pub(super) fn progressive_controls(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
budget_controls(app, ui);
let mut selected = usize::from(app.render_workspace.view.raytrace.progressive.is_some());
let labels = [app.tr("render.children.single_image").to_string(), app.tr("render.cpu_progressive").to_string()];
let response = ui.scope(|ui| {
ui_kit::row(ui, app.tr("render.children.sampling"), |ui| {
ui_kit::segmented(
ui,
&mut selected,
&labels.iter().map(String::as_str).collect::<Vec<_>>(),
ui_kit::Segments { compact: true, targets: &["render:cpu-fixed", "render:cpu-progressive"], ..Default::default() },
)
})
});
crate::scenario::publish_target("render:cpu-sampling", response.response.rect);
if response.inner {
set_progressive(app, selected == 1);
}
let enabled = app.render_workspace.view.raytrace.progressive.is_some();
let mut options = app.render_workspace.view.raytrace.progressive.unwrap_or_default();
if !enabled {
ui_kit::text(ui, app.tr("render.children.progressive_reason"), ui_kit::Text::Note("render.children.progressive_reason"));
}
ui.add_enabled_ui(enabled, |ui| {
let response =
ui_kit::row(ui, app.tr("render.cpu_progressive_spp"), |ui| ui_kit::number_field(ui, &mut options.samples_per_pixel, 1..=4096, 1.0, ""));
app.render_workspace.view.dirty |= response.changed();
crate::scenario::publish_target("render:cpu-spp", response.rect);
let response =
ui_kit::row(ui, app.tr("render.cpu_progressive_seed"), |ui| ui_kit::number_field(ui, &mut options.seed, 0..=u32::MAX, 1.0, ""));
app.render_workspace.view.dirty |= response.changed();
crate::scenario::publish_target("render:cpu-seed", response.rect);
});
if enabled {
app.render_workspace.view.raytrace.progressive = Some(options);
}
ui_kit::text(ui, app.tr("render.cpu_progressive_help"), ui_kit::Text::Note("render.cpu_progressive_help"));
enabled
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum Environment {
#[default]
None,
Constant,
Hdr,
}
#[derive(Clone, Debug)]
pub(super) struct View {
#[cfg(test)]
pub selected_body: Option<String>,
#[cfg(test)]
pub body_filter: String,
pub environment: Environment,
pub hdr: hdr::Input,
pub rotation_rad: f64,
pub intensity: f64,
pub samples: u32,
pub color: [f64; 3],
pub checked: Option<Checked>,
bindings: std::collections::BTreeMap<String, Binding>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Target {
ordinal: usize,
feature: u64,
component: u64,
}
#[derive(Clone, Debug)]
struct Binding {
target: Target,
revision: u64,
document: std::sync::Weak<solidcraft_engine::doc::Document>,
// Interactive may follow appearance/render-only edits only while this exact
// evaluated world survives in the same open document. Weak ownership avoids
// retaining old geometry and prevents pointer reuse after the world drops.
session_uid: u64,
world: std::sync::Weak<solidcraft_engine::doc::ModelState>,
}
#[derive(Clone, Debug)]
pub(super) struct Checked {
pub request: Value,
pub revision: u64,
pub environment: Value,
document: std::sync::Weak<solidcraft_engine::doc::Document>,
}
impl Default for View {
fn default() -> Self {
Self {
#[cfg(test)]
selected_body: None,
#[cfg(test)]
body_filter: String::new(),
environment: Environment::None,
hdr: hdr::Input::default(),
rotation_rad: 0.0,
intensity: 1.0,
samples: 16,
color: [1.0; 3],
checked: None,
bindings: Default::default(),
}
}
}
fn valid_name(name: &str) -> bool {
!name.is_empty() && name.len() <= 128 && !name.chars().any(char::is_control)
}
fn target(app: &SolveApp, name: &str) -> Option<Target> {
if !valid_name(name) || app.session.model.is_stale() {
return None;
}
let world = app.session.world_state();
let mut matches = world.bodies.iter().filter(|body| app.session.body_visible(&body.name)).enumerate().filter(|(_, body)| body.name == name);
let (ordinal, body) = matches.next()?;
if matches.next().is_some() {
return None;
}
Some(Target { ordinal, feature: body.feature, component: body.component })
}
fn unique_visible_body(app: &SolveApp, name: &str) -> bool {
target(app, name).is_some()
}
fn binding_current(app: &SolveApp, name: &str) -> bool {
app.render_workspace.view.cpu.bindings.get(name).is_some_and(|binding| {
binding.revision == app.session.revision
&& std::sync::Weak::ptr_eq(&binding.document, &std::sync::Arc::downgrade(&app.session.doc))
&& target(app, name).as_ref() == Some(&binding.target)
})
}
// Unlike final Check/Start, Interactive may refresh harmless appearance/render
// edits. The engine retains the exact world Arc for those edits (refresh_render).
// No geometry extraction, cold meshing, name-only rebinding or revision stamping.
fn binding_interactive_world_current(app: &SolveApp, name: &str) -> bool {
app.render_workspace.view.cpu.bindings.get(name).is_some_and(|binding| {
binding.session_uid == app.session.uid
&& !app.session.model.is_stale()
&& app
.session
.interactive_cached_world_state()
.is_some_and(|world| std::sync::Weak::ptr_eq(&binding.world, &std::sync::Arc::downgrade(&world)))
})
}
fn binding_interactive_current(app: &SolveApp, name: &str) -> bool {
binding_interactive_world_current(app, name)
&& app.render_workspace.view.cpu.bindings.get(name).is_some_and(|binding| target(app, name).as_ref() == Some(&binding.target))
}
pub(super) fn revalidate(app: &mut SolveApp, name: &str) -> Result<(), String> {
let current = target(app, name).ok_or_else(|| app.tr("render.cpu_body_missing").to_string())?;
if app.render_workspace.view.cpu.bindings.get(name).is_none_or(|binding| binding.target != current) {
return Err(app.tr("render.cpu_body_missing").to_string());
}
let revision = app.session.revision;
let document = std::sync::Arc::downgrade(&app.session.doc);
let world = std::sync::Arc::downgrade(&app.session.world_state());
let session_uid = app.session.uid;
if let Some(binding) = app.render_workspace.view.cpu.bindings.get_mut(name) {
binding.revision = revision;
binding.document = document;
binding.session_uid = session_uid;
binding.world = world;
}
Ok(())
}
/// A removed, hidden or ambiguous body never silently becomes a different target.
pub(super) fn set_reflection(app: &mut SolveApp, name: &str, mode: Option<ReflectionMode>) -> Result<(), String> {
let selected = if mode.is_some() { Some(target(app, name).ok_or_else(|| app.tr("render.cpu_body_missing").to_string())?) } else { None };
// Changing the mode of an existing row is not a new body selection. Only an
// explicit confirmation of the same target, or Off followed by Add, may
// establish a fresh binding after the document or body identity changes.
if mode.is_some()
&& app.render_workspace.view.raytrace.reflections.as_ref().is_some_and(|r| r.bodies.iter().any(|entry| entry.body == name))
&& !binding_current(app, name)
{
return Err(app.tr("render.cpu_binding_stale").to_string());
}
let message = app.tr("render.cpu_reflection_limit").to_string();
let options = &mut app.render_workspace.view.raytrace;
if let Some(mode) = mode {
let mut reflections = options.reflections.clone().unwrap_or_default();
if let Some(entry) = reflections.bodies.iter_mut().find(|entry| entry.body == name) {
entry.mode = mode;
} else {
if reflections.bodies.len() >= 64 {
return Err(message);
}
reflections.bodies.try_reserve_exact(1).map_err(|_| message.clone())?;
if reflections.bodies.capacity() > 64 {
return Err(message);
}
reflections.bodies.push(ReflectionBody { body: name.to_string(), mode });
}
options.reflections = Some(reflections);
} else if let Some(reflections) = &mut options.reflections {
reflections.bodies.retain(|entry| entry.body != name);
if reflections.bodies.is_empty() {
options.reflections = None;
}
}
if let Some(target) = selected {
app.render_workspace.view.cpu.bindings.insert(
name.to_string(),
Binding {
target,
revision: app.session.revision,
document: std::sync::Arc::downgrade(&app.session.doc),
session_uid: app.session.uid,
world: std::sync::Arc::downgrade(&app.session.world_state()),
},
);
} else {
app.render_workspace.view.cpu.bindings.remove(name);
}
Ok(())
}
/// Shared by the parent Check and Start builder. This does not read an HDR file.
pub(super) fn params(app: &SolveApp, params: &mut Value) -> Result<(), String> {
params_with_binding_policy(app, params, binding_current)
}
/// Interactive-only wire lowering. This does not requalify final Check/Start,
/// change a stored binding or read an HDR file; the engine still captures and admits it.
pub(super) fn params_interactive(app: &SolveApp, params: &mut Value) -> Result<(), String> {
// Refuse unproved bound worlds before shared lowering checks visibility via
// target()/world_state(). Empty reflections never require a warm world.
if app
.render_workspace
.view
.raytrace
.reflections
.as_ref()
.is_some_and(|r| r.bodies.iter().any(|entry| !binding_interactive_world_current(app, &entry.body)))
{
return Err(app.tr("render.cpu_binding_stale").to_string());
}
params_with_binding_policy(app, params, binding_interactive_current)
}
fn params_with_binding_policy(app: &SolveApp, params: &mut Value, current: fn(&SolveApp, &str) -> bool) -> Result<(), String> {
let base = &app.render_workspace.view.raytrace;
if base.anisotropic_faces.iter().any(Option::is_some)
|| !base.dielectrics.is_empty()
|| base.environment.is_some()
|| base.surface_effects.is_some()
|| base.emissions.is_some()
{
return Err(app.tr("render.cpu_options_pending").to_string());
}
if base.reflections.as_ref().is_some_and(|r| r.bodies.iter().any(|entry| !unique_visible_body(app, &entry.body))) {
return Err(app.tr("render.cpu_body_missing").to_string());
}
if base.reflections.as_ref().is_some_and(|r| r.bodies.iter().any(|entry| !current(app, &entry.body))) {
return Err(app.tr("render.cpu_binding_stale").to_string());
}
let view = &app.render_workspace.view.cpu;
let mut options = base.clone();
if options.area_samples.is_some_and(|n| !matches!(n, 1 | 4 | 16 | 64)) {
return Err(crate::i18n::tr("render.error_area_samples").into());
}
if options.area_lights.as_ref().is_none_or(Vec::is_empty) {
options.area_samples = None;
options.seed = None;
}
options.exposure_ev = app.render_workspace.view.draft.exposure_ev;
// Runtime CPU requests keep the same single saved EV authority as typed jobs.
params["settings"]["exposure_ev"] = json!(0.0);
if view.environment != Environment::None {
if !view.intensity.is_finite() || !(0.0..=64.0).contains(&view.intensity) {
return Err(app.tr("render.cpu_environment_bounds").to_string());
}
match view.environment {
Environment::Constant => options.environment = Some(RaytraceEnvironmentOptions { color: view.color, intensity: view.intensity }),
Environment::Hdr => {
if !view.rotation_rad.is_finite() || view.rotation_rad.abs() > 1e6 || !matches!(view.samples, 1 | 4 | 16 | 64) {
return Err(app.tr("render.cpu_environment_bounds").to_string());
}
let mut environment = hdr::request(app, hdr::Target::Cpu)?;
environment["rotation_radians"] = json!(view.rotation_rad);
environment["intensity"] = json!(view.intensity);
environment["samples"] = json!(view.samples);
params["environment"] = environment;
}
Environment::None => {}
}
}
options.validate().map_err(|error| error.to_string())?;
params["integrator"] = json!("raytrace");
params["raytrace"] = json!(options);
Ok(())
}
#[cfg(test)]
pub(super) fn controls(app: &mut SolveApp, ui: &mut egui::Ui) {
for (id, key, section) in [
("render_cpu_reflections", "render.cpu_reflections", 0),
("render_cpu_environment", "render.cpu_environment", 1),
("render_cpu_lights", "render.cpu_lights", 2),
] {
let title = app.tr(key).to_string();
ui_kit::section(ui, id, &title, false, |ui| match section {
0 => reflection_controls(app, ui),
1 => environment_controls(app, ui),
_ => light_controls(app, ui),
});
}
ui.small(app.tr("render.cpu_runtime_help"));
}
#[cfg(test)]
pub(super) fn reflection_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.small(app.tr("render.cpu_reflection_help"));
let entries = app.render_workspace.view.raytrace.reflections.as_ref().map(|r| r.bodies.clone()).unwrap_or_default();
for entry in entries {
ui.label(&entry.body);
if !binding_current(app, &entry.body) {
ui.small(app.tr("render.cpu_binding_stale"));
if ui_kit::button(ui, app.tr("render.cpu_binding_confirm"), ui_kit::Button::Normal).clicked()
&& let Err(error) = revalidate(app, &entry.body)
{
app.set_status(error, true);
}
}
let selected = match entry.mode {
ReflectionMode::Mirror => "render.cpu_reflection_mirror",
ReflectionMode::RoughSpecular => "render.cpu_reflection_rough",
};
let label = app.tr(selected).to_string();
let mode_control = ui_kit::dropdown_menu(ui, ("cpu_reflection_mode", &entry.body), &label, |ui| {
for (mode, key) in [
(None, "render.cpu_reflection_off"),
(Some(ReflectionMode::Mirror), "render.cpu_reflection_mirror"),
(Some(ReflectionMode::RoughSpecular), "render.cpu_reflection_rough"),
] {
let enabled = mode.is_none() || binding_current(app, &entry.body);
if ui.add_enabled_ui(enabled, |ui| ui_kit::list_picker(ui, key, app.tr(key), mode == Some(entry.mode))).inner.clicked() {
if let Err(error) = set_reflection(app, &entry.body, mode) {
app.set_status(error, true);
}
ui.close();
}
}
});
crate::scenario::publish_handle(&format!("render:cpu-reflection-mode:{}", entry.body), mode_control.rect.center());
}
let hint = app.tr("render.cpu_body_filter").to_string();
ui_kit::bounded_cell_editor(ui, ui.id().with("cpu_body_filter"), &mut app.render_workspace.view.cpu.body_filter, &hint, false, 128);
let selected = app.render_workspace.view.cpu.selected_body.clone();
let label = selected.clone().unwrap_or_else(|| app.tr("render.cpu_body_choose").to_string());
let world = app.session.world_state();
let filter = app.render_workspace.view.cpu.body_filter.to_lowercase();
let candidates: Vec<_> = world
.bodies
.iter()
.filter(|body| app.session.body_visible(&body.name) && body.name.to_lowercase().contains(&filter))
.take(257)
.map(|body| body.name.as_str())
.collect();
let dropdown = ui_kit::dropdown_menu(ui, "cpu_reflection_body", &label, |ui| {
egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| {
for (shown, name) in candidates.iter().enumerate() {
if shown == 256 {
ui.small(app.tr("render.cpu_body_filter_more"));
break;
}
let response =
ui.add_enabled_ui(valid_name(name), |ui| ui_kit::list_picker(ui, name, name, selected.as_deref() == Some(*name))).inner;
if response.clicked() {
app.render_workspace.view.cpu.selected_body = Some((*name).to_string());
ui.close();
}
}
});
});
crate::scenario::publish_handle("render:cpu-reflection-body", dropdown.rect.center());
let can_add = selected.as_ref().is_some_and(|name| unique_visible_body(app, name))
&& app.render_workspace.view.raytrace.reflections.as_ref().is_none_or(|r| r.bodies.len() < 64);
let add = ui_kit::button(ui, app.tr("render.cpu_reflection_add"), ui_kit::Button::Enabled(can_add));
crate::scenario::publish_handle("render:cpu-reflection-add", add.rect.center());
if add.clicked()
&& let Some(name) = selected
&& let Err(error) = set_reflection(app, &name, Some(ReflectionMode::RoughSpecular))
{
app.set_status(error, true);
}
reflection_sampling_controls(app, ui);
}
/// Existing global reflection budget, shared during the Sampling-editor migration.
pub(super) fn reflection_sampling_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
let depth = app.tr("render.cpu_reflection_depth").to_string();
let samples = app.tr("render.cpu_reflection_samples").to_string();
if let Some(reflections) = &mut app.render_workspace.view.raytrace.reflections {
ui_kit::row(ui, &depth, |ui| ui_kit::number_field(ui, &mut reflections.max_bounces, 1..=8, 1.0, ""));
ui_kit::row(ui, &samples, |ui| ui_kit::number_field(ui, &mut reflections.samples, 1..=64, 1.0, ""));
} else {
ui.small(app.tr("render.cpu_reflection_empty"));
}
}
#[cfg(test)]
pub(super) fn environment_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
let selected = app.render_workspace.view.cpu.environment;
let key = match selected {
Environment::None => "render.cpu_environment_none",
Environment::Constant => "render.cpu_environment_constant",
Environment::Hdr => "render.studio_hdr",
};
let label = app.tr(key).to_string();
ui_kit::dropdown_menu(ui, "cpu_environment_kind", &label, |ui| {
for (value, key) in [
(Environment::None, "render.cpu_environment_none"),
(Environment::Constant, "render.cpu_environment_constant"),
(Environment::Hdr, "render.studio_hdr"),
] {
if ui_kit::list_picker(ui, key, app.tr(key), value == selected).clicked() {
app.render_workspace.view.cpu.environment = value;
ui.close();
}
}
});
if app.render_workspace.view.cpu.environment == Environment::None {
return;
}
if app.render_workspace.view.cpu.environment == Environment::Constant {
let color = app.tr("render.cpu_linear_color").to_string();
vector(ui, &color, &mut app.render_workspace.view.cpu.color, 0.0..=1.0, 0.01, "");
ui.small(app.tr("render.cpu_constant_help"));
} else {
hdr::controls(app, ui, hdr::Target::Cpu);
ui.small(app.tr("render.cpu_hdr_help"));
ui.label(app.tr("render.studio_rotation"));
let mut degrees = app.render_workspace.view.cpu.rotation_rad.to_degrees();
if ui_kit::number_field(ui, &mut degrees, -1e6_f64.to_degrees()..=1e6_f64.to_degrees(), 1.0, "°").changed() {
app.render_workspace.view.cpu.rotation_rad = degrees.to_radians();
}
ui.label(app.tr("render.cpu_environment_samples"));
let samples = app.render_workspace.view.cpu.samples;
ui_kit::dropdown_menu(ui, "cpu_hdr_samples", &samples.to_string(), |ui| {
for value in [1, 4, 16, 64] {
if ui_kit::list_picker(ui, &value.to_string(), &value.to_string(), value == samples).clicked() {
app.render_workspace.view.cpu.samples = value;
ui.close();
}
}
});
ui.small(app.tr("render.cpu_hdr_reflection_help"));
}
ui.label(app.tr("render.studio_intensity"));
ui_kit::number_field(ui, &mut app.render_workspace.view.cpu.intensity, 0.0..=64.0, 0.1, "");
ui.small(app.tr("render.cpu_environment_background"));
}
#[cfg(test)]
fn vector(ui: &mut egui::Ui, label: &str, value: &mut [f64; 3], range: std::ops::RangeInclusive<f64>, speed: f64, suffix: &str) {
ui.label(label);
ui.horizontal(|ui| {
for component in value {
ui_kit::number_field(ui, component, range.clone(), speed, suffix);
}
});
}
#[cfg(test)]
pub(super) fn light_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
super::light::lighting(app, ui);
}
pub(super) fn record_checked(app: &mut SolveApp, params: &Value, reply: &Value) {
if params["integrator"] == "raytrace" || params["settings"]["job"]["method"] == "cpu_raytrace" {
app.render_workspace.view.cpu.checked = Some(Checked {
request: params.clone(),
revision: app.session.revision,
environment: reply["environment"].clone(),
document: std::sync::Arc::downgrade(&app.session.doc),
});
}
}
fn metadata(app: &SolveApp, ui: &mut egui::Ui, info: &Value) {
let Some(hash) = info["source_sha256"].as_array().filter(|hash| hash.len() == 32) else { return };
let digest: Option<Vec<_>> = hash.iter().map(|byte| byte.as_u64().and_then(|n| u8::try_from(n).ok())).collect();
let Some(digest) = digest else { return };
let digest: String = digest.into_iter().map(|byte| format!("{byte:02x}")).collect();
let width = info["header"]["width"].as_u64().unwrap_or(0);
let height = info["header"]["height"].as_u64().unwrap_or(0);
ui.label(format!("{width} × {height} px"));
ui.label(format!("{}: {}", app.tr("render.cpu_environment_samples"), info["samples"].as_u64().unwrap_or(0)));
ui.label(format!("{}: {:.2}", app.tr("render.studio_intensity"), info["intensity"].as_f64().unwrap_or(0.0)));
ui.label(format!("{}: {:.1}°", app.tr("render.studio_rotation"), info["rotation_radians"].as_f64().unwrap_or(0.0).to_degrees()));
ui.label(format!("SHA-256: {digest}"));
}
pub(super) fn checked_status(app: &SolveApp, ui: &mut egui::Ui, params: Option<&Value>) {
if app.render_workspace.view.method != super::ImageMethod::CpuRaytrace {
return;
}
if let Some(checked) = &app.render_workspace.view.cpu.checked {
let title = app.tr("render.cpu_last_check").to_string();
ui_kit::section(ui, "cpu_last_check", &title, false, |ui| {
if params != Some(&checked.request)
|| checked.revision != app.session.revision
|| !std::sync::Weak::ptr_eq(&checked.document, &std::sync::Arc::downgrade(&app.session.doc))
{
ui.small(app.tr("render.cpu_check_changed"));
}
metadata(app, ui, &checked.environment);
ui.small(app.tr("render.cpu_check_capture_help"));
});
}
}
pub(super) fn captured_status(app: &SolveApp, ui: &mut egui::Ui, status: &Value) {
if status["request"]["integrator"] == "raytrace" && status["environment"]["source_sha256"].is_array() {
let title = app.tr("render.cpu_captured_hdr").to_string();
ui_kit::section(ui, "cpu_captured_hdr", &title, false, |ui| metadata(app, ui, &status["environment"]));
}
}
@@ -0,0 +1,600 @@
//! Opt-in CPU checkpoints. This cache and its capture receiver are app-wide,
//! never part of saved setups, final results or export ownership.
use super::ImageMethod;
use crate::{SolveApp, theme::Tokens, ui_kit};
use serde_json::{Value, json};
#[cfg(not(target_arch = "wasm32"))]
use solidcraft_engine::render_interactive::Snapshot;
use solidcraft_engine::{
render::job::{
FrameInfo, HostKind, Integrator, JobId,
interactive::{CaptureTicket, State as Phase, VisualRevision},
},
render_interactive::{InteractiveRender, SceneIdentity},
schema::render_ui::Action,
};
use std::{sync::mpsc, time::Duration};
struct Intent {
scene: SceneIdentity,
uid: u64,
revision: u64,
camera: solidcraft_engine::render::Camera,
stale: bool,
request: Result<Value, String>,
}
pub(super) struct Pending {
pub(super) uid: u64,
pub(super) ticket: CaptureTicket,
pub(super) receiver: mpsc::Receiver<Result<bool, String>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct PixelKey {
job: JobId,
sequence: u32,
complete: bool,
}
struct Cached {
key: PixelKey,
revision: VisualRevision,
info: FrameInfo,
samples: u32,
stale: bool,
texture: Result<egui::TextureHandle, String>,
}
pub(super) struct State {
context: Option<InteractiveRender>,
pub(super) pending: Option<Pending>,
issued: Option<CaptureTicket>,
observed: Option<Intent>,
visual: u64,
clock: Duration,
phase: Phase,
user_paused: bool,
final_active: bool,
error: Option<String>,
frame: Option<Cached>,
}
impl Default for State {
fn default() -> Self {
Self {
context: None,
pending: None,
issued: None,
observed: None,
visual: 0,
clock: Duration::ZERO,
phase: Phase::Off,
user_paused: false,
final_active: false,
error: None,
frame: None,
}
}
}
impl State {
/// Never drop a live receiver to make room for a second capture thread.
pub(super) fn reset_document(&mut self) {
if let Some(context) = self.context.take() {
let _ = context.disable();
}
self.issued = None;
self.observed = None;
self.phase = Phase::Off;
self.user_paused = false;
self.final_active = false;
self.error = None;
self.frame = None;
}
#[cfg(test)]
pub(super) fn pending(&self) -> bool {
self.pending.is_some()
}
fn revision(&self) -> Option<VisualRevision> {
self.observed.as_ref().map(|intent| VisualRevision { scene_generation: intent.revision, visual_revision: self.visual })
}
fn drain(&mut self, app: &SolveApp) {
let Some(pending) = self.pending.take() else { return };
let reply = match pending.receiver.try_recv() {
Ok(reply) => reply,
Err(mpsc::TryRecvError::Empty) => {
self.pending = Some(pending);
return;
}
Err(mpsc::TryRecvError::Disconnected) => Err(app.tr("render.interactive_error").into()),
};
if pending.uid != app.session.uid
|| self.issued != Some(pending.ticket)
|| self.revision() != Some(pending.ticket.revision())
|| self.context.as_ref().is_none_or(|context| !context.is_current(&app.session))
{
return;
}
// Final preemption or a user pause invalidates the outstanding ticket;
// an expected rejected capture is not a fresh preparation failure.
if self.user_paused
|| self.final_active
|| self.context.as_ref().is_some_and(|context| matches!(context.state(), Ok(Phase::Paused | Phase::Off)))
{
return;
}
match reply {
Err(error) => {
if let Some(context) = &self.context {
let _ = context.capture_failed(pending.ticket, error.clone());
}
self.error = Some(error);
self.phase = Phase::Error;
}
Ok(false) => {
self.error = Some(app.tr("render.interactive_error").into());
self.phase = Phase::Error;
}
Ok(true) => {}
}
}
fn observe(&mut self, app: &SolveApp) -> Result<(), String> {
// The exact-input stamp never computes world placements/meshes.
// Preserve a current stamp across request-only edits; its weak identities
// prevent pointer reuse without retaining CAD data or changing COW behavior.
let stale = app.session.model.is_stale();
let request = if stale { Err(app.tr("render.error_finish_gesture").into()) } else { super::interactive_request(app) };
let current_scene = self.observed.as_ref().is_some_and(|old| old.scene.is_current(&app.session));
if current_scene
&& self.observed.as_ref().is_some_and(|old| {
old.uid == app.session.uid
&& old.revision == app.session.revision
&& old.camera == app.cam
&& old.stale == stale
&& old.request == request
})
{
return Ok(());
}
let scene = match self.observed.take() {
Some(old) if current_scene => old.scene,
_ => app.session.interactive_scene_identity(),
};
let intent = Intent { scene, uid: app.session.uid, revision: app.session.revision, camera: app.cam, stale, request };
let recovering = self.error.is_some() || self.phase == Phase::Error;
self.visual = self.visual.checked_add(1).ok_or_else(|| app.tr("render.interactive_error").to_string())?;
self.observed = Some(intent);
self.issued = None;
self.error = None;
if self.user_paused {
if let Some(frame) = &mut self.frame {
frame.stale = true;
}
} else {
self.frame = None;
}
if let (Some(context), Some(revision)) = (&self.context, self.revision()) {
context.edit(revision, self.clock).map_err(|error| error.to_string())?;
if recovering && !self.user_paused {
context.resume(self.clock).map_err(|error| error.to_string())?;
}
}
if !stale && let Some(Err(error)) = self.observed.as_ref().map(|intent| &intent.request) {
self.error = Some(error.clone());
}
Ok(())
}
fn update(&mut self, app: &mut SolveApp, ctx: &egui::Context, dispatch: bool) -> Result<(), String> {
self.clock = monotonic(self.clock, ctx.input(|input| input.time));
self.drain(app);
if self.context.is_none() {
return Ok(());
}
if !super::active(app)
|| app.render_workspace.view.method != ImageMethod::CpuRaytrace
|| self.context.as_ref().is_some_and(|context| !context.is_current(&app.session))
{
self.reset_document();
return Ok(());
}
self.observe(app)?;
let final_active = finals_active(app);
let suspend = final_active.as_ref().copied().unwrap_or(true) || app.render_workspace.view.result;
self.final_active = suspend;
let waiting_for_model = self.observed.as_ref().is_some_and(|intent| intent.stale);
let context = self.context.as_ref().ok_or_else(|| app.tr("render.interactive_error").to_string())?;
context.set_final_active(suspend || waiting_for_model, self.clock).map_err(|error| error.to_string())?;
if suspend || waiting_for_model {
self.issued = None;
}
if suspend && !self.user_paused {
self.frame = None;
}
if let Err(error) = final_active {
self.error = Some(error);
}
if waiting_for_model {
self.phase = if self.user_paused || suspend { Phase::Paused } else { Phase::Updating };
if let Some(frame) = &mut self.frame {
frame.stale = true;
}
return Ok(());
}
if self.error.is_some() {
// Request errors must cancel an already attached old capture, too.
context.pause().map_err(|error| error.to_string())?;
self.phase = Phase::Error;
return Ok(());
}
self.phase = context.state().map_err(|error| error.to_string())?;
if self.phase == Phase::Error {
self.error = context.error().map_err(|error| error.to_string())?.or_else(|| Some(app.tr("render.interactive_error").into()));
self.frame = None;
return Ok(());
}
self.cache(ctx)?;
if !dispatch || self.pending.is_some() || suspend || self.user_paused {
return Ok(());
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(ticket) = self
.context
.as_ref()
.ok_or_else(|| app.tr("render.interactive_error").to_string())?
.capture(self.clock)
.map_err(|error| error.to_string())?
{
let request = self
.observed
.as_ref()
.and_then(|intent| intent.request.as_ref().ok())
.ok_or_else(|| app.tr("render.interactive_error").to_string())?;
let snapshot = self
.context
.as_ref()
.ok_or_else(|| app.tr("render.interactive_error").to_string())?
.snapshot(&app.session, ticket, request)
.map_err(|error| error.to_string())?;
self.issued = Some(ticket);
self.pending = Some(spawn(snapshot)?);
self.phase = Phase::Updating;
}
Ok(())
}
fn cache(&mut self, ctx: &egui::Context) -> Result<(), String> {
if self.user_paused || self.final_active {
return Ok(());
}
let Some(context) = &self.context else { return Ok(()) };
let Some(job) = context.current().map_err(|error| error.to_string())? else { return Ok(()) };
let Some(ticket) = self.issued else { return Ok(()) };
if job.id() != ticket.job_id() || self.revision() != Some(ticket.revision()) {
return Ok(());
}
let status = job.status().map_err(|error| error.to_string())?;
if status.request.integrator != Integrator::Raytrace || status.scene_generation != ticket.revision().scene_generation {
return Err(crate::i18n::tr("render.interactive_error").into());
}
// Prefer an immutable checkpoint. A completed 16-SPP preview is also
// provisional and is never inserted into view.job/completed/result slots.
let preview = job.preview().map_err(|error| error.to_string())?;
if let Some(preview) = preview {
let key = PixelKey { job: job.id(), sequence: preview.info.frame_sequence, complete: false };
self.cache_png(ctx, key, ticket.revision(), &preview.info.frame, preview.info.samples_per_pixel, (preview.png(), &status.request))?;
} else if let Some(frame) = job.frame().map_err(|error| error.to_string())? {
let key = PixelKey { job: job.id(), sequence: 0, complete: true };
let samples = status.request.raytrace.as_ref().and_then(|options| options.progressive).map_or(0, |options| options.samples_per_pixel);
self.cache_png(ctx, key, ticket.revision(), &frame.info, samples, (frame.png(), &status.request))?;
}
Ok(())
}
fn cache_png(
&mut self,
ctx: &egui::Context,
key: PixelKey,
revision: VisualRevision,
info: &FrameInfo,
samples: u32,
payload: (&[u8], &solidcraft_engine::render::job::ImageRequest),
) -> Result<(), String> {
let (png, request) = payload;
if self.frame.as_ref().is_some_and(|frame| frame.key == key && frame.revision == revision) {
return Ok(());
}
if info.job != key.job
|| info.scene_generation != revision.scene_generation
|| !(1..=16).contains(&samples)
|| (info.width, info.height) != (request.width, request.height)
|| info.width > 1024
|| info.height > 1024
{
return Err(crate::i18n::tr("render.error_provisional_identity").into());
}
let texture = super::decode_render_image(
png,
info.width,
info.height,
super::captured_image_limits(request),
ctx.input(|input| input.max_texture_side),
)
.map(|image| ctx.load_texture("sc_render_interactive", image, egui::TextureOptions::LINEAR));
let error = texture.as_ref().err().cloned();
self.frame = Some(Cached { key, revision, info: info.clone(), samples, stale: false, texture });
if let Some(error) = error {
return Err(error);
}
Ok(())
}
}
pub(super) fn monotonic(previous: Duration, seconds: f64) -> Duration {
if !seconds.is_finite() || seconds < 0.0 {
return previous;
}
match Duration::try_from_secs_f64(seconds) {
Ok(now) => previous.max(now),
Err(_) => previous,
}
}
fn finals_active(app: &SolveApp) -> Result<bool, String> {
let mut active = false;
for session in std::iter::once(&app.session).chain(app.docs.background()) {
active |= session.render_final_active().map_err(|error| error.to_string())?;
}
Ok(active)
}
pub(super) fn ready(app: &SolveApp) -> Result<(), String> {
if cfg!(target_arch = "wasm32") || !super::active(app) || app.render_workspace.view.method != ImageMethod::CpuRaytrace {
return Err(app.tr("render.interactive_requires_cpu").into());
}
let host = app.session.render_job_host().ok_or_else(|| app.tr("render.interactive_requires_cpu").to_string())?;
if host.kind() != HostKind::NativeWorker || !host.is_available() || host.validate_integrator(Integrator::Raytrace).is_err() {
return Err(app.tr("render.interactive_requires_cpu").into());
}
if finals_active(app)? || app.render_workspace.view.result {
return Err(app.tr("render.interactive_final_active").into());
}
Ok(())
}
pub(super) fn apply_command(app: &mut SolveApp, action: Action) -> Result<(), String> {
if matches!(action, Action::On | Action::Resume) {
ready(app)?;
}
let mut state = std::mem::take(&mut app.render_workspace.interactive);
let result = (|| {
match action {
Action::Off => state.reset_document(),
Action::On => {
state.reset_document();
state.context = Some(app.session.interactive_render().map_err(|error| error.to_string())?);
state.observe(app)?;
let revision = state.revision().ok_or_else(|| app.tr("render.interactive_error").to_string())?;
state
.context
.as_ref()
.ok_or_else(|| app.tr("render.interactive_error").to_string())?
.enable(revision, state.clock)
.map_err(|error| error.to_string())?;
state.phase = Phase::Updating;
}
Action::Pause => {
let context = state.context.as_ref().ok_or_else(|| app.tr("render.interactive_requires_cpu").to_string())?;
context.pause().map_err(|error| error.to_string())?;
state.user_paused = true;
state.issued = None;
state.phase = Phase::Paused;
}
Action::Resume => {
state.observe(app)?;
if let Some(Err(error)) = state.observed.as_ref().map(|intent| &intent.request) {
return Err(error.clone());
}
let context = state.context.as_ref().ok_or_else(|| app.tr("render.interactive_requires_cpu").to_string())?;
context.resume(state.clock).map_err(|error| error.to_string())?;
state.user_paused = false;
state.issued = None;
state.error = None;
state.frame = None;
state.phase = Phase::Updating;
}
}
Ok(())
})();
app.render_workspace.interactive = state;
result
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn(snapshot: Snapshot) -> Result<Pending, String> {
let uid = snapshot.document_uid();
let ticket = snapshot.ticket();
let (sender, receiver) = mpsc::channel();
let _worker = std::thread::Builder::new()
.name("solidcraft-interactive-capture".into())
.spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| snapshot.run()))
.map_err(|_| crate::i18n::tr("render.interactive_error").to_string())
.and_then(|result| result.map_err(|error| error.to_string()));
let _ = sender.send(result);
})
.map_err(|error| error.to_string())?;
Ok(Pending { uid, ticket, receiver })
}
fn refresh(app: &mut SolveApp, ctx: &egui::Context, dispatch: bool) {
let mut state = std::mem::take(&mut app.render_workspace.interactive);
if let Err(error) = state.update(app, ctx, dispatch) {
if let Some(context) = &state.context {
let _ = context.pause();
}
state.error = Some(error);
state.phase = Phase::Error;
state.frame = None;
}
let poll = state.pending.is_some() || matches!(state.phase, Phase::Updating | Phase::Refining);
app.render_workspace.interactive = state;
if poll {
ctx.request_repaint_after(Duration::from_millis(100));
}
}
pub(super) fn tick(app: &mut SolveApp, ctx: &egui::Context) {
refresh(app, ctx, true);
}
pub(super) fn before_paint(app: &mut SolveApp, ctx: &egui::Context) {
refresh(app, ctx, false);
}
pub(super) fn paint(app: &SolveApp, ui: &egui::Ui, rect: egui::Rect) -> bool {
let state = &app.render_workspace.interactive;
if state.context.is_none() || state.final_active || state.error.is_some() {
return false;
}
let Some(frame) = &state.frame else { return false };
if !state.user_paused && (frame.stale || state.revision() != Some(frame.revision)) {
return false;
}
let Ok(texture) = &frame.texture else { return false };
let scale = (rect.width() / frame.info.width as f32).min(rect.height() / frame.info.height as f32);
let target = egui::Rect::from_center_size(rect.center(), egui::vec2(frame.info.width as f32, frame.info.height as f32) * scale);
ui.painter().with_clip_rect(rect).image(
texture.id(),
target,
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
Tokens::get().file_image_tint,
);
true
}
pub(crate) fn overlay(app: &mut SolveApp, ui: &egui::Ui, free: egui::Rect) {
let width = (free.width() - Tokens::get().space_8 * 2.0).min(Tokens::get().form_card_width);
if width < Tokens::get().control_height {
return;
}
let card = ui_kit::Container::navigation_card(
ui.ctx(),
egui::Id::new("sc_render_interactive_controls"),
free.center_top(),
egui::Align2::CENTER_TOP,
|ui| {
ui.set_width(width);
ui.set_max_width(width);
ui.set_clip_rect(ui.clip_rect().intersect(free));
controls(app, ui);
},
);
crate::shell::register_panel(app, "render:interactive-card", Some(card.response.rect.intersect(free)), None);
crate::scenario::publish_rect("render:interactive-card", card.response.rect.intersect(free));
}
pub(super) fn controls(app: &mut SolveApp, ui: &mut egui::Ui) {
let state = &app.render_workspace.interactive;
let phase = state.phase;
let running = state.context.is_some();
let paused = state.user_paused;
let key = match phase {
Phase::Off => "render.interactive_off",
Phase::Updating => "render.interactive_updating",
Phase::Refining => "render.interactive_refining",
Phase::Paused => "render.interactive_paused",
Phase::TargetReached => "render.interactive_target",
Phase::Error => "render.interactive_error",
};
let label = app.tr(key).to_string();
let error = state.error.clone();
let samples = state.frame.as_ref().map(|frame| frame.samples);
let dimensions = state.frame.as_ref().map(|frame| (frame.info.width, frame.info.height));
let stale = state.frame.as_ref().is_some_and(|frame| frame.stale);
let final_active = state.final_active;
ui_kit::text(ui, &label, ui_kit::Text::Inline);
if running {
ui_kit::text(ui, app.tr("render.interactive_provisional"), ui_kit::Text::Note("render.interactive_provisional"));
}
if let Some(samples) = samples {
let label = crate::i18n::format("render.interactive_samples", &[("done", &samples.to_string()), ("total", "16")]);
ui_kit::text(ui, &label, ui_kit::Text::Inline);
}
if let Some((width, height)) = dimensions {
ui_kit::text(ui, &format!("{width} × {height} px"), ui_kit::Text::Inline);
}
if stale {
ui_kit::text(ui, app.tr("render.interactive_stale"), ui_kit::Text::Note("render.interactive_stale"));
}
if final_active {
ui_kit::text(ui, app.tr("render.interactive_final_active"), ui_kit::Text::Note("render.interactive_final_active"));
}
if let Some(error) = error {
ui_kit::text(ui, &error, ui_kit::Text::Error("render.interactive_error"));
}
let reason = ready(app).err();
if let Some(reason) = &reason {
ui_kit::text(ui, reason, ui_kit::Text::Note("render.interactive_requires_cpu"));
}
let mut action = None;
ui.horizontal_wrapped(|ui| {
for (action_value, name, key, enabled) in if !running {
vec![(Action::On, "on", "render.interactive_on", reason.is_none())]
} else {
vec![
(
if paused || phase == Phase::Error { Action::Resume } else { Action::Pause },
if paused || phase == Phase::Error { "resume" } else { "pause" },
if paused || phase == Phase::Error { "render.interactive_resume" } else { "render.interactive_pause" },
!final_active && (reason.is_none() || !(paused || phase == Phase::Error)),
),
(Action::Off, "off", "render.interactive_off", true),
]
} {
let label = app.tr(key).to_string();
let response = ui_kit::button(ui, label, ui_kit::Button::Enabled(enabled));
crate::scenario::publish_target(&format!("render:interactive:{name}"), response.rect);
if response.clicked() {
action = Some(action_value);
}
}
});
if let Some(action) = action {
if let Err(error) = app.run("render.interactive", json!({"action":action})) {
app.set_status(error, true);
}
ui.ctx().request_repaint();
}
}
pub(super) fn command_ready(app: &SolveApp) -> Result<(), String> {
if app.render_workspace.interactive.context.is_some() { Ok(()) } else { ready(app) }
}
pub(super) fn activate(app: &mut SolveApp) {
let state = &app.render_workspace.interactive;
let action = if state.context.is_none() {
Action::On
} else if state.user_paused || state.phase == Phase::Error {
Action::Resume
} else {
Action::Pause
};
if let Err(error) = app.run("render.interactive", json!({"action":action})) {
app.set_status(error, true);
}
}
pub(super) fn inspect(app: &SolveApp) -> Value {
let state = &app.render_workspace.interactive;
json!({"enabled":state.context.is_some(),"phase":format!("{:?}",state.phase),"visual_revision":state.visual,
"pending_capture":state.pending.is_some(),"paused":state.user_paused,"error":state.error,
"samples":state.frame.as_ref().map(|frame| frame.samples),"stale":state.frame.as_ref().is_some_and(|frame|frame.stale),
"job":state.frame.as_ref().map(|frame|frame.info.job),"provisional":true})
}
// Read-only private observations for original UI acceptance tests. No production
// command/API, host factory, renderer injection, worker gate or timing hook.
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(super) fn test_current(app: &SolveApp) -> Option<solidcraft_engine::render::job::JobHandle> {
app.render_workspace.interactive.context.as_ref()?.current().unwrap()
}
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(super) fn test_cache(app: &SolveApp) -> Option<(FrameInfo, u32, bool, egui::TextureId)> {
let frame = app.render_workspace.interactive.frame.as_ref()?;
Some((frame.info.clone(), frame.samples, frame.key.complete, frame.texture.as_ref().ok()?.id()))
}
// Harness frames advance this clock even while Interactive is Off.
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(super) fn test_clock(app: &SolveApp) -> Duration {
app.render_workspace.interactive.clock
}
@@ -0,0 +1,285 @@
//! Interactive adapters for existing registered commands, shared by ribbon,
//! menus, search and pinned job controls. Direct engine JSON APIs are unchanged.
use super::{cpu, inspector, library, setups, studio};
use crate::SolveApp;
use serde_json::json;
use solidcraft_engine::render::job::Phase;
use solidcraft_engine::{CommandInfo, CommandSpec};
fn is_job_action(id: &str) -> bool {
matches!(id, "render.preflight" | "render.start" | "render.cancel" | "render.export" | "render.result.regrade" | "render.result.copy")
}
fn handled(id: &str) -> bool {
is_job_action(id)
|| matches!(
id,
"render.interactive"
| "render.aging.describe"
| "render.effects.describe"
| "render.environment.set"
| "render.preset.resolve"
| "render.preset.apply"
| "render.settings.get"
| "render.settings.set"
| "render.shot.duplicate"
| "render.shot.rename"
| "render.shot.remove"
| "render.library.appearance.apply"
| "render.optics.bind"
| "render.optics.unbind"
| "render.library.surface.bind"
| "render.library.surface.unbind"
| "render.library.surface.prepare"
| "render.library.physical.prepare"
| "render.library.studio.prepare"
| "render.library.studio.apply"
| "render.library.environment.apply"
| "render.stage.ground"
| "render.stage.backdrop"
| "render.stage.clear"
| "render.hdr.inspect"
| "render.settings.capture_hdr"
| "render.settings.apply"
| "render.camera.fit"
| "render.camera.get"
| "render.camera.set"
| "render.quality.prepare"
| "render.status"
| "render.backends"
| "render.result.view"
| "render.result.close"
)
}
pub(super) fn busy(app: &SolveApp) -> bool {
app.render_workspace
.view
.job
.and_then(|job| app.session.render_job_handle(job).ok())
.and_then(|job| job.status().ok())
.is_some_and(|s| matches!(s.progress.phase, Phase::Queued | Phase::Preparing | Phase::Running | Phase::Encoding | Phase::Cancelling))
}
/// Reads only current host/job metadata and the numerical draft. No preflight,
/// scene preparation, device discovery, resource IO or rendering during painting.
pub(super) fn action_ready(app: &SolveApp, id: &str) -> Result<(), String> {
if app.file_load.busy() || app.file_picker.is_some() || inspector::confirmation_open(app) {
return Err(app.tr("render.ribbon_finish_dialog").into());
}
match id {
"render.interactive" => super::interactive::command_ready(app),
"render.preflight" | "render.start" => {
if id == "render.start" && busy(app) {
return Err(app.tr("render.ribbon_job_active").into());
}
super::image_params(app).map(|_| ())
}
"render.result.regrade" => {
if busy(app) {
return Err(app.tr("render.ribbon_job_active").into());
}
if !studio::linear_export_available(app) {
return Err(app.tr("render.linear_unavailable").into());
}
presentation(app).validate().map_err(|error| error.to_string())
}
"render.cancel" if !busy(app) => Err(app.tr("render.ribbon_no_active_job").into()),
"render.result.close" if !super::result_active(app) => Err(app.tr("render.ribbon_no_result").into()),
"render.export" | "render.result.copy" => {
let completed = app
.render_workspace
.view
.completed
.and_then(|id| app.session.render_job_handle(id).ok())
.and_then(|handle| handle.frame().ok().flatten());
if completed.is_none() {
return Err(app.tr("render.ribbon_no_result").into());
}
if id == "render.export" && app.render_workspace.view.studio.export_exr && !studio::linear_export_available(app) {
return Err(app.tr("render.linear_unavailable").into());
}
Ok(())
}
"render.settings.set" => setups::snapshot(app).map(|_| ()),
"render.settings.apply" if !app.session.doc.render.setups.contains_key(&app.render_workspace.view.name) => {
Err(app.tr("render.ribbon_no_saved_camera").into())
}
_ => Ok(()),
}
}
pub(super) fn command_info(app: &SolveApp, command: &CommandSpec) -> CommandInfo {
// Play History swaps to Stop History while it runs, wherever the command is shown.
if command.id == "timeline.play" {
return crate::timeline::playback_command_info(app, command);
}
let mut info = command.info(&app.session);
if info.enabled
&& super::active(app)
&& handled(command.id)
&& let Err(reason) = action_ready(app, command.id)
{
info.enabled = false;
info.disabled_reason = Some(reason);
}
info
}
/// Execute the same explicit request from either the ribbon or the sticky footer.
/// No action falls back to a default executor, saved shot or a different result.
pub(super) fn perform_job(app: &mut SolveApp, id: &str) -> Result<(), String> {
action_ready(app, id)?;
match id {
"render.preflight" => {
let request = super::image_params(app)?;
let reply = match app.run(id, request.clone()) {
Ok(reply) => reply,
Err(error) => {
inspector::record_check(app, &request, &json!({"error":error}));
return Err(error);
}
};
cpu::record_checked(app, &request, &reply);
inspector::record_check(app, &request, &reply);
app.set_status(app.tr("render.image_supported").to_string(), false);
}
"render.start" => {
let request = super::image_params(app)?;
let reply = app.run(id, request)?;
let job = serde_json::from_value(reply["job"].clone())
.map_err(|e| crate::i18n::format("render.error_job_identity", &[("error", &e.to_string())]))?;
app.render_workspace.view.job = Some(job);
app.render_workspace.view.job_shot = Some(app.render_workspace.view.name.clone());
}
"render.result.copy" => {
let job = app.render_workspace.view.completed.ok_or_else(|| app.tr("render.ribbon_no_result").to_string())?;
app.run(id, json!({"job":job}))?;
}
"render.result.regrade" => {
let source = app.render_workspace.view.completed.ok_or_else(|| app.tr("render.ribbon_no_result").to_string())?;
let reply = app.run(id, json!({"job":source,"presentation":presentation(app)}))?;
let job = serde_json::from_value(reply["job"].clone())
.map_err(|error| crate::i18n::format("render.error_regrade_identity", &[("error", &error.to_string())]))?;
app.render_workspace.view.job = Some(job);
}
"render.cancel" => {
let job = app.render_workspace.view.job.ok_or_else(|| app.tr("render.ribbon_no_active_job").to_string())?;
app.run(id, json!({"job":job}))?;
}
"render.export" => {
let job = app.render_workspace.view.completed.ok_or_else(|| app.tr("render.ribbon_no_result").to_string())?;
let overwrite = app.render_workspace.view.overwrite;
if app.render_workspace.view.studio.export_exr {
super::export_linear_image(app, job, overwrite);
} else {
super::export_image(app, job, overwrite);
}
}
_ => return Err(crate::i18n::tr("render.error_job_action").into()),
}
Ok(())
}
pub(super) fn presentation(app: &SolveApp) -> solidcraft_engine::render::job::presentation::Presentation {
let view = &app.render_workspace.view;
solidcraft_engine::render::job::presentation::Presentation {
exposure_ev: view.draft.exposure_ev,
tone_map: view.studio.tone_map,
finishing: view.studio.finishing,
backdrop: view.studio.backdrop_enabled.then_some(view.studio.backdrop),
background: view.draft.background,
transparent_background: view.transparent,
}
}
fn focus(app: &mut SolveApp, page: inspector::Page, category: Option<&str>) {
app.render_workspace.view.inspector.hidden = false;
let kind = match page {
inspector::Page::Sampling => "sampling",
inspector::Page::Effects => "effects",
inspector::Page::Diagnostics => "check",
inspector::Page::Output => "output",
_ => "nothing",
};
let _ = app.run("view.render_select", json!({"kind":kind}));
app.render_workspace.view.inspector.page = page;
if let Some(category) = category {
library::open_category(app, category);
}
}
/// Parameterless command activation opens the already implemented editor. The
/// engine's explicit resolve/apply methods still require their original params.
pub(super) fn start(app: &mut SolveApp, id: &str) -> bool {
if id == "render.interactive" {
super::interactive::activate(app);
return true;
}
if !handled(id) {
return false;
}
if !super::active(app) {
if let Err(error) = super::enter(app, true) {
app.set_status(error, true);
return true;
}
// Entering from Design never starts an expensive render with hidden defaults.
focus(app, inspector::Page::Output, None);
if is_job_action(id) {
return true;
}
}
let result = if is_job_action(id) { perform_job(app, id) } else { navigate(app, id) };
if let Err(error) = result {
app.set_status(error, true);
}
true
}
fn navigate(app: &mut SolveApp, id: &str) -> Result<(), String> {
action_ready(app, id)?;
use inspector::Page;
match id {
"render.aging.describe" => {
focus(app, Page::Appearance, None);
inspector::reveal(app, "render:appearance:advanced");
}
"render.result.close" => {
app.run(id, json!({}))?;
}
"render.result.view" => {
app.run(id, json!({"view":"image"}))?;
}
"render.effects.describe" => focus(app, Page::Effects, None),
"render.preset.resolve" | "render.preset.apply" => focus(app, Page::Scene, Some("native_scenes")),
"render.settings.get" | "render.shot.duplicate" | "render.shot.rename" | "render.shot.remove" => {
focus(app, Page::Scene, None);
inspector::reveal(app, "render_shot_manager");
}
"render.settings.set" => app.render_workspace.view.save_shot_name = Some(String::new()),
"render.library.appearance.apply" | "render.optics.bind" | "render.optics.unbind" => focus(app, Page::Appearance, Some("surfaces")),
"render.library.surface.bind" | "render.library.surface.prepare" | "render.library.surface.unbind" => {
focus(app, Page::Appearance, Some("surfaces"));
}
"render.library.physical.prepare" => focus(app, Page::Appearance, Some("physical_profiles")),
"render.library.studio.prepare" => focus(app, Page::Lighting, Some("rigs")),
"render.library.studio.apply" => focus(app, Page::Lighting, None),
"render.library.environment.apply" => focus(app, Page::Environment, Some("hdr")),
"render.environment.set" | "render.hdr.inspect" | "render.settings.capture_hdr" => focus(app, Page::Environment, None),
"render.stage.backdrop" => focus(app, Page::Background, None),
"render.stage.ground" | "render.stage.clear" => {
focus(app, Page::Background, None);
inspector::reveal(app, "render_ground_details");
}
"render.settings.apply" => inspector::restore_camera(app)?,
"render.camera.fit" => inspector::fit_subject(app)?,
"render.camera.get" | "render.camera.set" => focus(app, Page::Camera, None),
"render.quality.prepare" => {
focus(app, Page::Output, Some("quality"));
library::review_selected_quality(app)?;
}
"render.status" | "render.backends" => focus(app, Page::Diagnostics, None),
_ => return Err(crate::i18n::tr("render.error_interactive_command").into()),
}
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
# CPU Interactive core
First slice of #8936 under #6201. `job::interactive` prepares bounded CPU
progressive requests and coordinates their lifetime. **It is not yet connected
to the workspace UI.** Entering Render does not start a job. No default,
document format, final export, panel or gizmo is changed by this slice.
`cpu_request` clones an immutable final request. It preserves the camera,
background, shadows, exposure, lighting, resources and transport. Only preview
dimensions and camera/reflection sampling change: maximum 1024 pixels along the
longest edge, rounded to the nearest pixel without enlargement, 16 SPP, seed 0,
one primary and one selected reflection attempt per camera sample. The original
final request stays untouched. Integer aspect rounding is at most half a pixel
(or one pixel for an extreme aspect). Transparent output, another integrator or
unsupported progressive transport returns an error instead of dropping effects.
The existing profile and host still enforce geometry, traversal and memory limits.
## Adapter contract
- Allocate a distinct transient job session for each document's Interactive
controller. Never recreate a controller with the same session while callbacks
from its previous instance can arrive. Do not register its jobs in the engine's
final/export registry. Even its 16-SPP completed image is provisional content.
- Increment `VisualRevision.visual_revision` whenever camera, draft, material,
visibility, lighting, HDR/resource, method or output intent changes. Include
changes that leave the document revision unchanged. Capture this counter and
`scene_generation` atomically with the full request and scene. This core does
not infer that identity from a setup name or a partial hash.
- Explicit activation calls `enable`; edits call `edit`. Supply monotonic elapsed
`Duration` values. `capture` issues one ticket after 200 ms without a newer edit.
Do not evaluate geometry or read files every frame just to compare identity.
- Recheck `accepts_capture(ticket)` before expensive capture/admission and after
worker preparation. This avoids preparing an already obsolete request; it does
not replace the mandatory identity checks in `attach` after admission.
- Capture through the existing authoritative engine boundary, prepare the CPU
request, then admit/submit through the installed CPU host using
`CaptureTicket::job_id()`. Pass the resulting handle to `attach`
with the original ticket. Delayed jobs are cancelled, including a wrong serial
or generation in our dedicated preview session. A foreign session is rejected
without cancelling its work, even if its handle arrived through a misrouted
callback. Final jobs must keep their distinct engine session.
Report a failed capture through `capture_failed`; failure waits for an edit or
explicit resume, rather than retrying every frame.
- Read only `current()` to publish checkpoint pixels. The existing host provides
immutable previews, sample counts, sequence and final target bytes. Their job
and revision must remain attached to the displayed image. A copied handle or
retained texture is not permission to display it as current after an edit.
- Pause may retain an immutable checkpoint in the adapter, explicitly labelled
paused and stale after edits. It cancels work. Resume uses the latest revision.
Leave Render or change documents with `disable`; Drop also cancels owned work.
- Before a manual final starts, call `set_final_active(true)`. This cancels only
Interactive work and prevents new capture tickets. Call it with false after the
final finishes; an enabled, unpaused controller then groups the latest edits
again. No method here cancels a final job or exports an image.
The UI adapter, registered activation/pause commands, current-request identity
capture, retained paused texture and visual acceptance remain a following slice
coordinated with #8000/#7375. This is not a claim that those integration gates or
the installed application's shadow defect are fixed.
+264
View File
@@ -0,0 +1,264 @@
# Render: current reviewable source and qualification boundaries
30 October2026. These are canonical source checkpoints requested by Judit. Production modules on this archive branch remain unchanged. Source/math review, written tests and a running validation are different from an observed runtime pass or ready PR.
| Packet | Current meaning |
| --- | --- |
| [Complete53-path C12 candidate](c12-complete-source/README.md) | One patch against draft base a0779062; core corrections, Interactive capture/cache/lifecycle, waiting/input/layout, help translations, GPU reasons and Area/LED. Applied locally and frozen for current C12. Do not compose this with the earlier incremental patches. |
| [Actual-worker Area/LED E2E tests](cpu-progressive-area-led/E2E.md) | Included in the complete candidate. Two original native-host/Snapshot/PNG/cache/stale-final-priority cases; C12 built successfully but stopped on selection before test bodies; C13 tests-only pending. |
| [Nested certified-box glass V2](cpu-nested-boxes-v2-source-review/README.md) and [current cumulative rebind](cpu-nested-boxes-v2-source-review/CURRENT-REBIND.md) | Independent numerical and Engine source review,15 new Render/Engine declarations; NOT APPLIED/COMPILED/RUN. V1 remains blocked. General/progressive/Interactive glass and physical-library routing remain open. |
| [EV/HDR real-worker additions](ev-hdr-e2e/README.md) | Separate2-test proposal on the213-test C12 prefix; independently source-reviewed, unrun and not part of C12's53-case selection. |
| [Explicit output-envelope helpers](output-envelope-helpers/README.md) | Seven-helper V2/13 original tests, independently source-reviewed; no CPU host/raw-linear/work/publication enablement. Unapplied/uncompiled/unrun. |
| [Genuine midpass progress foundation](midpass-film-preparation/README.md) | Option A selected. A1 partial-film accessor +5 originals reviewed; still unintegrated, expected unused API until a REAL A2 producer exists. No lint allowances/dummy consumers. Host/producer/UI implementation is being prepared privately under disjoint ownership. |
| [Raster perspective candidate](parity-raster-main66bd/README.md) | Source-reviewed against its actual newer main API, not this older frozen candidate. Original perspective/legacy controls, no compiler/runtime claim or permission to overlay it blindly. |
| [Native observations](native-followups/native-round-one.md) and [remaining EV/HDR/final journeys](native-followups/ev-hdr-final-acceptance-proposal.md) | First older-binary round was partial. Fresh current-source app build and mouse/keyboard acceptance remain pending; source tests do not close native UX. |
## New source packets and original detailed fixture
- [C13 pinned-action correction](c13-pinned-actions/README.md): independently source-reviewed, unrun; original failing assertion retained.
- [Actual A1/A2 partial producer and host](partial-stream-source-v1/README.md): written source and original tests, unapplied/uncompiled/unrun; combined review and UI receiver still pending.
- [Original19-body long-render fixture and synthetic HDR](original-long-scene/README.md): privacy-reviewed source subset, attributed; no native creation/render/quality result.
## Current validation
C12 is terminal: formatter/check, repository lists, strict affected Clippy, default-feature wasm and the combined engine/UI/Render native library-test build exited0. Six inventories also exited0. Selection stopped before any test body because two names used the wrong module prefix (`job::tests` instead of `job::runtime_request_roundtrip_tests`). **Zero test bodies and zero CPU markers ran; this is a preparation error, not a product failure or53 passes.** Independent closure verified the13 exited command roots/empty owned Jobs, source/config/toolchain, the actual three CargoJSON artifacts and physical lease cleanup.
C13 is terminal, stopped without retry on a **real UI assertion failure**. Six fresh inventories succeeded; two exact native cases executed: **1 passed,1 failed;51 were not run**. The failing case retained both expected positive CPU markers and exited101: at760×700, Pause was outside the Interactive card clip (card y327–381; button y380–408). This is not a skipped CPU branch or parser failure. No Cargo/build ran. All eight command roots ended and owned Jobs drained; source and artifacts remained unchanged. The original test/assertions and failure evidence are preserved. An isolated layout fix is being prepared and independently reviewed before any new validation grant. Current-source native application and long-scene quality acceptance still remain pending.
The real i18n gate invokes a cached release CLI-registry build internally. Its exterior exit0 does not independently prove that registry's success because a helper can swallow the error. That evidence limit is preserved. A uniquely identified lingering compiler auxiliary was explicitly disposed after its command exited; that administrative action is not a product/test pass. No control receipts, tokens or machine logs are public here.
CPU final output should cover GPU-supported effects, including real glass, and coherent maximum-resolution admission/finishing/export. Current16SPP Interactive remains a responsive provisional default, not the final quality ceiling. Long renders need genuine intermediate pixels, truthful unknown/sample provenance and bounded retained images. Raster is explicitly schematic. More samples/completion alone do not prove clean, correct or converged output.
The [active tracker and acceptance plan](https://github.com/tangency-labs/solidcraft/issues/6201) records dependencies/owners and preserves historical results. Existing Nathan-Dane panels/Results/Studio/gizmos and other contributors' GPU/compiler/color work retain their ownership.
<details>
<summary>Earlier source-preservation snapshot (historical; current table above supersedes statuses)</summary>
# Render follow-up source preservation, 30 October 2026
These are unqualified work-in-progress snapshots and narrow patches, preserved at Judit's request. Production files on this archive branch remain unchanged. The earlier [complete source and pending parity packet](../2026-10-29/README.md) remains intact. This directory does not register feature modules or claim a ready delivery.
- `source/`: exact current two UI sources after the test-only Result correction. Other combined source remains as in the earlier checkpoint.
- `test-current-result.patch`: preserves controller errors rather than unwrapping in a test helper; six callers retain their original Some/None assertions. Applied locally; not yet revalidated.
- `cpuarea-existing-help-25-catalogs.patch`: conditional on the pending CPU Area/LED progressive implementation. Removes only the existing translated Area-light refusal and its list separators. No new translated claims, IDs, caps or defaults. UNAPPLIED and UNRUN. Do not apply before the corresponding behavior lands.
- `raster-reciprocal-w-append.patch`: append-only original regression for the pending weighted-scalar perspective fix from the earlier packet. Preserves all existing tests. UNAPPLIED and UNRUN.
## Original Raster oracle
With eye (0,-10,0), clip w=(2,8,4) and sample (2.5,2.5), screen barycentrics (1/4,1/2,1/4) give perspective weights (1/2,1/4,1/4). Independently intersecting E+t*(-3/8,1,3/8) with 4x-y-4z=-6 gives t=4 and world=(-3/2,-6,3/2). Normal (3,1,0)/sqrt(10) separates the correct view from affine and fixed-back alternatives. Closed-form dielectric specular with F0=.04, roughness=1 gives radiance .014412012452814404; Reinhard plus sRGB rounds to byte32. The test expects [32,32,32,255]; the two wrong-view controls predict zero. This is an analytical prediction and static source review, not an executed baseline/candidate result. It observes view direction, not distance along the same ray.
## Validation status
C3 built the combined native engine/UI test executables and passed 81 selected cases (33 engine,48 UI), including 17 positive CPU markers. Strict Clippy then failed on the old test-helper unwrap; wasm was not started. The test-only Result change preserved here was applied after that execution and still awaits its affected tests and strict checks. Subsequent C4/C5 attempts stopped at RAM admission BEFORE formatter/Cargo/test execution. Neither is a product failure or a pass. No GPU, native GUI acceptance or full repository CI is claimed.
The original 516-case core result remains bound to its exact unchanged core sources. Native GUI acceptance and actual GPU dependencies remain separate. Pending proposals must be integrated narrowly against real current source, reviewed and validated before production publication.
## Latest engine correction and native acceptance plan
[Current validation status](STATUS.md) supersedes the earlier snapshot above. The [engine test-helper Result correction](engine-test-helper-result.patch) and exact engine sources are now preserved here. [Native mouse/keyboard acceptance](native-mouse-keyboard-acceptance.md) is planned after validation and an actual application build; it has not been executed.
</details>
<!-- protected-combined-source-20261030 -->
## Protected CPU progressive-feedback source
[Complete27-file source checkpoint](protected-combined-source/README.md), including31 new unrun producer/host regressions and the protected receiver over the previous reviewed A2 foundation. Exact text composition only: unapplied, unformatted, uncompiled and unrun; cross-module review pending. Manual-final streaming, general/progressive glass, library routing, finishing and native long-scene cadence/quality remain open. This cumulative patch is an alternative to overlapping earlier increments, never an extra layer on top of them.
<!-- next-render-source-20261030-v1 -->
## Next Render source packets
[Source-only continuation](next-render-source/README.md) preserves progressive certified optics, the exact-index straight-path correction and original camera-TIR tests; explicit CPU material routing and its progressive/caption deltas; manual-final feedback V1 plus mandatory V2 corrections; the receiver-bound Raster perspective fix; and separate Photographic test-contract/cursor-commit corrections. Before/candidate hashes and dependencies distinguish cumulative alternatives from incremental deltas. All new packets remain unapplied, unformatted, uncompiled and unrun. Actual C14 is separately36PASS/1FAIL/108unrun after a successful three-library build and original Pause regression; it is not full CI or current-source native/GPU acceptance.
<!-- actual19-reviewed-source-20261030 -->
## Exact19-path adopted Render composition
[Actual19 cumulative source](actual19-reviewed-source/README.md) preserves the independently source-reviewed V2 that ROOT adopted by exact RAW copy, with3571 members checked. It combines manual-final feedback, progressive certified optics and both W2 suffixes, CPU material routing/bridge/caption, UI252 with its explicit supported-feature/DTO-stage corrections, both Photographic fixes and Raster perspective/directional-core source. Reconstruct the exact previous C14 post-format base; this is an alternative to overlapping increments, never another layer on top of them. LF hunks and eight CRLF endpoints are explicitly bound. New66 declarations and planned242-case/49-marker selection are **unrun**; formatting/build/actual gates/native acceptance are still pending. Historical C14 remains36PASS/1FAIL/108unrun and no pass transfers.
<!-- c16-defaults-auto-result-source-20261030 -->
## C16 source: fixture defaults and Result navigation
[C16 exact five-file source](c16-defaults-auto-result/README.md) is independently source-reviewed and adopted by RAW copy; the adjacent12-file C15 formatting bridge makes the Actual19→C15 post-format→C16 lineage explicit. It fixes the four invalid new-fixture calls and adds preference-compliant Result navigation after accepted GUI Start, retaining old behavior with `render_open_result_on_start=false`. Four new declarations remain unrun. C15 stays an actual four-E0599 build failure with0 test bodies/242 cases unrun; no build/test/native PASS transfers. Manifests record exact endpoint hashes/EOL and cumulative alternatives; no private execution files are published.
<!-- render-progress-evidence-20261030T1813 -->
## Progress by consolidated issue
[Current U01-U17 evidence-milestone chart after C17](progress-c17/README.md): 17 open items, partial code in13 and earlier partial native improvements in8; none fully resolved end to end. C17 ran69 cases:68 passed,1 failed,184 remain unrun; no full current gate or desktop/GPU acceptance. These are milestones, not effort percentages or an ETA. [Earlier pre-C17 snapshot](progress/README.md) is preserved as history.
<!-- next-render-source-units-20261030 -->
## Separate reviewed source units
[Camera/framing and Raster directional source units](next-source-units/README.md) preserve reviewed, unrun code without adopting or combining it. Camera/framing has3 original UI cases on C16 and leaves geometry/placement/visibility identity open. Raster preserves its33-file C15 archival base plus mandatory DTO-path, empty-capacity and borrowed-JSON fixes,14 new tests and explicit current UI/W2 rebind obligations. No source review or historical C15/C16 outcome is a runtime PASS; the later W2 core-policy packet is excluded until delivered/reviewed.
## Reviewed HDR, sampling and CPU exposure follow-ups (30 October)
Additive source proposals; prior checkpoint sections remain unchanged. This append was prepared against observed archive head 9001a87ea4b6c4473055dd690b31435a77a79a33 on codex/render-all-code-checkpoint-20261029. Recheck the actual branch head/index before integrating it.
- [HDR digest test representation correction](reviewed-followups/hdr-hash-test/README.md): exact test-only patch adopted in C18 development source, execution tracked separately in #6201; no C17 failure is relabeled.
- [Sampling presets and future persistence correction](reviewed-followups/sampling-presets/README.md): choose legacy OR the ordered PR8000 sampling/persistence alternative, never both; unadopted and unrun.
- [CPU display exposure±32 alternatives](reviewed-followups/cpu-exposure-32/README.md): exact standalone OR TIR-layer EV delta, six unrun new tests and retained original negative-contract source bodies. The TIR prerequisite chain is referenced, unprovided here and not installed; this is not a complete TIR code archive.
See the [unit index](reviewed-followups/README.md) and each portable bindings file. These source archives do not establish full CI, native GUI/GPU acceptance or readiness.
## Coherent preview and exposure source checkpoint
[C19 source, bindings and unrun original tests](coherent-preview-exposure/README.md)
preserve the full reviewed-source assembly for saved Raster lights, common
Model/CPU/Raster exposure, captured-image boundaries and paused framing.
This archive is not adoption, a test pass or production readiness.
## Explicit product sRGB source proposal
[Two-file shader proposal and unrun original GPU test](explicit-product-srgb/README.md)
preserve the narrow explicit-light color-transfer correction against exact C19
source. One test proposes 96 renders; none has run. Ordinary Design/implicit
preservation is source-scoped, and Some(lights) selects the changed branch.
This archive does not authorize adoption or execution.
## Connected Render documentation v2 source proposal
[Two reviewed CRLF documentation deltas](connected-render-docs-v2/README.md)
record the Interactive/Shot source contracts and corrected bounded dielectric
wording. Only the cumulative patch from exact C19 RAW endpoints is included.
The docs are unadopted; C19 build results and the separate U17 proposal do not
qualify these edits or authorize runtime/adoption.
## Strict CPU dielectric source proposal
[Ten-path strict-TIR proposal and 18 unrun original tests](strict-cpu-dielectric/README.md)
preserve default/omitted Legacy behavior and explicit bounded Strict intent.
This pre-format C19 delta requires a separate post-format receiver rebind; the
current C19 build does not include or qualify it. Only the combined patch is
archived, with no adoption, runtime, energy-convergence or GPU-parity claim.
## C20 reviewed working-source composition
[Exact C20 delta, portable bindings and 19 unrun declarations](c20-reviewed-composition/README.md)
record the reviewed 16-path composition adopted as working source only. No
compiled inventory, runtime, full-CI or readiness result is implied. Earlier
Strict TIR and U17 proposal archives do not substitute for this exact receiver.
## Raster local studio v2: separate future unit
[Standalone candidate and delta](raster-local-studio-v2/README.md) preserve the
source-closed helper with 18 new numeric cases, all unrun. It has no consumer
and is not part of C20 or its development selection.
## U17 test-module label correction
[Source wrapper correction](explicit-product-srgb/test-module-label-correction.md) fixes `view::tests` to the actual `view::glass_tests` registration. No source, case selection or runtime result changes; the real GPU case remains unrun and excluded from C20 native development acceptance.
## CPU direct-visibility foundation: separate source proposal
[Two-file candidate, exact delta and original cases](cpu-direct-visibility-foundation/README.md)
preserve a bounded incremental straight exterior-light connection through certified
sheets/disjoint boxes. The 33 old plus 12 new declarations are all unrun on this
candidate. There is no image consumer or C20 adoption; brute-scan cost, refusal
domain, cumulative work/memory admission and missing real-image acceptance remain
explicit. Source review and replay are not compilation, full CI or GPU parity.
## 2026-10-31: minimal carried image-test recovery
[Minimal image test recovery](../2026-10-31/minimal-image-test-recovery/README.md) archives two exact call-site repairs in the existing Render image tests, both RAW snapshots, the unchanged delta and portable bindings. 23 original declarations / 0 new; all original oracles remain. C20 compiler failure is preserved; C21 is source-adopted only, with 0 executed cases and the 349-case/60-marker selection still UNRUN. No PASS/full-CI/GPU/desktop readiness is inferred.
## 2026-10-31: Raster local runtime consumer — source only
[Bounded Raster local-light consumer](../2026-10-31/raster-local-runtime/README.md) preserves exact before/candidate source for 34 existing paths, a single combined delta and portable bindings. It connects transient point/spot/rectangle schematic lights through request admission and execution, with bounded resource accounting and corrected weighted-work progress. Four receivers retain the current formatting and two test-compilation repairs. The 37 new cases remain unrun; the actual GPU Model case is excluded from native-only validation. This proposal is outside C21 and supplies no saved-light authoring, shadows, physical/material parity, CI or installed-app acceptance.
## 2026-10-31: CPU direct-visibility owner — source-only successor
[Captured owner and incremental cursor](../2026-10-31/cpu-direct-visibility-owner/README.md) follows the earlier direct-visibility foundation: an opaque inline owner keeps its scene/options by value and cannot resume a certified cursor against a different caller-supplied capture. The existing borrowed API and 45 original test bodies remain; seven new continuity, terminal and cumulative-work cases are unrun. This two-file successor has no image consumer and is outside C21; it is not standalone on the current checkout or proof of colored shadows, layout, lint, borrow-checking or native quality.
## 2026-10-31: original native CPU acceptance cases G01/G02 — not run
[Mouse/keyboard acceptance procedures](../2026-10-31/native-g01-g02-acceptance/README.md) preserve G01's original 19-body progressive/cancel/export requirement and add the distinct two-box physical-glass G02 case. G02 requires an actual completed IOR1.50 Strict image; a preserved error cannot pass it, and Standard observations remain separate. Actual CAD certification, optical-edit pixel refresh, stale-body recovery and CPU optical persistence are explicit obligations/limitations. Source-derived risks are labelled; all native observations are NOT_RUN. These are editable CAD recipes and procedures, with no bundled models, screenshots, binaries, full sources or runtime results. They do not replace the original 24 cases or constitute an execution grant.
## 2026-10-31: CPU RGB light-transmission shading — source-only prerequisite
[RGB direct-light shading helper](../2026-10-31/cpu-rgb-lighting/README.md) preserves the existing boolean lighting API and eight original test bodies, adding a separate per-light RGB-transmission method and seven analytic/boundary cases. The complete 15-case source set is unrun. It applies finite admitted channel values once without exposure or tone-map changes; invalid values/errors are refused. There is no ImageTask consumer, mode flag, UI, saved-format or render result, and this helper is outside C21. The owned-cursor and RGB proposals remain separate prerequisites for a later coherently reviewed consumer.
## 2026-10-31: physical-glass library to existing editor — source proposal
[Explicit physical-recipe staging](../2026-10-31/physical-glass-library-staging/README.md) adds a bounded action from the six typed starter glass entries to the existing whole-body optical editor. It preserves scalar assignment refusals, authored Closed/Thin choices, current target identity and existing permissions; staging is not Applied, CPU opt-in, certification or persistence. Four paths include one new pending-locale file. The 267 existing test bodies remain and ten original source cases are unrun. No new panel, renderer switch, GPU-guard removal, TIR convergence change or native acceptance is claimed. The new locale's absent before-state is explicit, with no fabricated empty before file.
## 2026-10-31: Interactive dielectric lowering contract
[Three-file test-contract alignment](../2026-10-31/interactive-dielectric-lowering-contract/README.md) preserves the exact valid glass payload with full typed local-retention checks, all other original negatives and new invalid-thickness/IOR controls, plus precise capability documentation. Same 8 declarations / 0 new and 1 disclosed oracle change; full fixture remains inadmissible. Source-only/unformatted/uncompiled/unrun, not a product fix or C21 PASS. Sole delta, before/candidate RAW snapshots and portable bindings are included.
## CPU directional RGB image consumer — source-only proposal, 31 October
[Twelve-file source packet and limitations](../2026-10-31/cpu-directional-rgb-consumer/README.md).
The complete API consumer connects captured direct visibility and RGB shading to bounded CPU image continuations, preserving legacy mode by default. Sixteen new tests are written but UNRUN. All before/candidate sources, unchanged RGB references and the exact delta are included; mixed ancestry and Raster composition requirements are explicit. This is a source archive, not an adopted complete workspace, UI feature-parity claim, test pass or production readiness result.
## C22 composed and adopted source — uncompiled and unrun, 31 October
[Coherent 43-path aggregate increment and source bindings](../2026-10-31/c22-composed-source/README.md). All seven reviewed units are preserved, including the original numeric oracles and two explicit neutral schema fillers. One new locale; 89 new test declarations UNRUN. A single current locked/offline metadata capture passed without source/lock changes; this is not compilation or test evidence. Staged development validation, desktop/native/GPU acceptance and full repository gates remain pending; production PR head is unchanged.
### 31 October — C23 quality budget controls and minimal TIR test-call adapter
[Three-path source and exact delta](../2026-10-31/c23-three-path-source/README.md). Existing GPU review/acknowledgement/currentness/save preserved;74old inspector tests and original TIR oracles retained,7newUI tests UNRUN. No universal CPU/Raster quality presets, compilation, native/GPU/full-CI or readiness claim.
### 31 October — CPU glass directional-shadow UI bridge
[Four-path source and request-boundary documentation](../2026-10-31/cpu-directional-shadow-ui/README.md). UniformON/OFF reaches the existing producer; mixed assignments refuse explicitly. Original277cases retained, one old unsupported-feature frontier deliberately updated,7newcasesUNRUN. Included in C23source only, not native/image/GPU/full-CI qualification.
- [U04 original GPU owner patches: proposed source integration](../2026-10-31/u04-owner-integrated-source/README.md) — Twelve literal hunks from plexi32's #6813 and #6850, original tests and RAW bindings. Not adopted; Rust/Naga/GPU/native UNRUN.
## C24 existing quality-test macro expression (2026-10-31)
[One-file checkpoint, exact two-parentheses delta and source bindings](../2026-10-31/c24-two-parentheses/README.md). Immediate base is C23 post-format source, not the earlier unformatted public candidate. No production change or new tests; C24 resource admission stopped before combined Cargo, with 953 cases and 60 required marker contracts UNRUN. No gate/readiness PASS inferred.
## CPU progressive high-pass film and Interactive controls — 31 October
[Nine source candidates and exact bindings](../2026-10-31/cpu-high-pass-ui-source/README.md)
preserve the source-reviewed 1,048,576 Workstation ceiling, u32 film resolve and
explicit Interactive target/seed controls. Thirteen new tests are written and
UNRUN. **Producer/host per-pass and u32 adapters are still pending; not adoptable
alone, compiled, installed, ready or merged.** Existing budget limits remain.
## CPU every-pass composition after C27 — 31 October
[Twenty-five exact post-format source files and portable bindings](../2026-10-31/cpu-every-pass-c27-source/README.md)
preserve the complete CPU proposal and its actual failed-build source. C27
format/check exited 0; combined compilation stopped on the existing 1,024-byte
metadata-size assertion (E0308). All 982 selected cases are UNRUN; the private
layout repair, full CI and native each-pass acceptance remain pending. This
supersedes the earlier nine-file snapshot for continuation, not production readiness.
## C27 metadata recovery: single active SHA state — 31 October
[One exact host-source successor and bindings](../2026-10-31/c27-sha-state-recovery/README.md)
propose serial PNG/EXR hashing with one active SHA state and the completed PNG
digest. The 1,024-byte assertion remains unchanged. Kernel review and actual
compilation are pending at curation; the planned 983 cases are all UNRUN.
Apply only on the exact C27 failed-source composition, not arbitrary main.
The single-host recovery has now been independently source-reviewed and adopted locally. Compilation, all 983 planned cases and native each-pass image acceptance remain pending.
## Progressive CPU compatibility WIP, isolated fixture and exact C28 host — 31 October
[Separated source packages and exact bindings](../2026-10-31/progressive-compatibility-wip/README.md)
preserve 34 uncompiled WIP candidates plus the actual earlier formatted C28 host
(35 source files). C28: 28 PASS / 1 font-fixture FAIL / 954 UNRUN, two actual
CPU-supported markers; no postchecks/native acceptance. The corrected test is
UNRUN. Compatibility remains NOT ADOPTED, UNCOMPILED and UNRUN; fixed Record
ABI/accounting and one P3 parameter-list omission remain pending. No full CI or
readiness claim, no implied composition of overlapping UI and font-fix files.
## Completed Result overlay — separate uncomposed WIP, 31 October
[Two RAW source proposals, exact before files and readable diff](../2026-10-31/result-overlay-wip/README.md)
preserve a three-line Result-view overlay guard and one original appended UI
regression. **Unreviewed / UNRUN / not adopted or ready.** Compose separately with
the case-976 font fix and overlapping compatibility work; never overwrite them
with this older whole-file workspace-test candidate. No C28 result transfers.
## Original native G01/G02 documents — preservation, not new results
[Six original acceptance documents and portable source bindings](../2026-10-31/native-acceptance-g01-g02/README.md)
preserve the G01 opaque and G02 glass recipes, actual-control maps and route
correction. These are historical source-bound procedures, **not six new tests or
new native cases**. All native observations in this packet remain NOT_RUN;
no model, image, executable, successful render or runtime result is included.
Rebind the actual application and source before exercising these obligations.
+31
View File
@@ -0,0 +1,31 @@
# Scoped validation result, 30 October 2026
Production files on this source-archive branch remain unchanged.
## C7: completed and independently checked
- Formatter and format check passed with no source changes.
- Strict all-target engine/UI Clippy with the CPU feature passed.
- Default-feature engine/UI wasm compilation check passed with three warnings; it was not warning-free and did not execute wasm.
- The combined native engine/UI library-test build passed. Both current CargoJSON artifacts were checked against actual package/source/manifest/features and typed ci profiles before execution.
- Both complete/ignored inventories passed. All22selected cases passed once each in separate serial processes:10engine and12UI,14positiveCPU markers and0false. No physicalGPU case was executed or counted as a pass.
- All31executed phases exited0 and recorded owned Job active_after0. Final process/lease closure and all3569source hashes, HEAD/index and both executable hashes were independently rechecked.
Actual native library-test executable SHA256:
- Engine: `82d6cb306c582f5e9ae7a0ec50b0abe776ff0803d3fe9431dca41f8a80a5e8a3` (bundled-render-library,default,raytrace-cpu).
- UI: `89c31de5b7dd73ff7390ec684388bf04b77c81645f234471cdd23183fb804eab` (no crate features).
This is bounded affected/dependent validation, **not full repository CI, a production application build, GPU qualification or installed-app acceptance**. Native tests use the ci profile; no alternate fast compiler output is substituted.
## What the source correction does
The two engine snapshots and patch resolve eleven test-scaffolding diagnostics: four fixture helpers propagate errors via Result and actual test bodies unwrap at their assertion boundary; the session-identity test uses a struct initializer. The earlier UI helper Result correction is also preserved. No original assertion, renderer behavior, lint allowance, work limit or expected image was weakened.
C3's earlier81selected passes and the scoped516-case renderer result remain exact-source historical evidence. C4/C5 stopped before Cargo for RAM admission. C6 passed admission and stopped on the engine test-helper diagnostics; no later C6 stage ran. Original failure evidence is retained.
## Next: actual application, then mouse/keyboard acceptance
C7 built library tests, not the application. A source-bound production `solidcraft` binary build is being prepared with the existing cache. The adjacent native mouse/keyboard acceptance plan is still **PLANNED, NOT RUN**. It will distinguish the installed release from the rebuilt candidate, use an owned synthetic design, preserve user documents, and drive real controls rather than internal product commands.
PhysicalGPU acceptance, full repository CI and native-application acceptance remain open. CPUArea/LED, locale and Raster proposals in this archive remain conditional/unrun; preservation is not their production integration.
@@ -0,0 +1,30 @@
# Actual19 — adopted, independently source-reviewed Render candidate
This cumulative19-path composition was **actually adopted by exact RAW candidate copy** after the independent final V2 source review. ROOT verified3571 source members and preserved Git HEAD/index and unrelated WIP. **The new source remains unformatted, uncompiled and unrun.** It is not a validation pass, ready PR or complete CPU/Raster/GPU parity. The production draft remains #8938 at `a077906234972dbbf11e80c54893f85ece97c8b7`.
## Exact base and one cumulative application
Use the [previous protected27 foundation](../protected-combined-source/README.md), then the [actual C14 post-format delta](../next-render-source/c14-postformat-delta.patch) preserved at archive commit `0175e0c5e618a6308d92818e911e8be1e43fe0c8`. The authoritative base manifest is `78a09f83c809f11e919c5d6957438389f442d42b05694aebcd39fcdb1786010d`. Production HEAD alone, latest main or the pre-format protected27 source is insufficient.
This is a **cumulative ALTERNATIVE** to the overlapping [earlier private increments](../next-render-source/README.md), not an additional layer on top of them. Reconstruct the exact C14 post-format base and use this complete composition once. The manifests preserve every before/candidate hash, layer provenance and actual adoption/review binding; the older variants remain history.
Concatenate all numbered `candidate-*.patch` files in order. Their exact concatenation must have SHA256 `946351c9a15cf1d42329d9abfed6f6ac8a39a7ff11427337a0ca933d1df259b8`. The unified hunks are **LF-normalized text**, while eight candidate endpoints require CRLF. The public manifest records RAW hashes, sizes, before/candidate EOL counts and those eight paths. Verify the original RAW preimages, normalize only physical CRLF line endings for textual patch reconstruction, restore each candidate's declared EOL, and verify its exact RAW SHA256. Do not claim universal literal RAW replay or use fuzzy patching. Actual adoption copied already verified RAW endpoint files instead of applying normalized hunks. Two separately documented W1 files retain pre-C14 formatting; the sole builder still needs to format and freeze the resulting actual source.
## Included implementation and original acceptance source
- Manual-final CPU progressive feedback through the actual captured request, protected admission and same final JobId, with provisional cache/report authority kept separate from retained final/export bytes. The mandatory late-Completed reconciliation and valid2x1 HDR fixture corrections are included. The ROOT engine compatibility V2 fixture uses a narrow field of view that prevents primary misses and retains the independent `[151,132,188]` PNG oracle.
- Progressive certified smooth optics, both previously overlapping W2 test suffixes, exact-index straight continuation for actual certified shells, and natural camera-TIR acceptance source:23 new W2 declarations are retained. The general branching model still has finite-depth quality limits; no general closed-mesh/rough-glass/indirect transport claim follows.
- Explicit default-off CPU material/library routing through the existing typed CPU request, progressive UI bridge and method-specific certification note, preserving actual target identity, optical coefficients, lights/HDR/seed, geometry certification and unsupported-intent refusals. Nateaux's panel/result layout remains the base.
- UI252 includes all224 original post-format declarations as the exact478983-byte prefix,23 routing declarations and five manual-final/optical cases. The three new blanket fixed-only negatives have a documented supported-feature adaptation. Its V2 correctly distinguishes the pre-engine Fixed DTO (`progressive=None`) from actual Interactive snapshots (`16SPP/seed0`); actual native status, conflict, saved-intent and default-nesting assertions remain.
- Both separate Photographic corrections: the stronger new cancellation-contract test and the independent transactional cursor-commit product fix. Historical `finish` behavior after recognized completion is retained. This does not retroactively erase the C14 failure.
- The receiver-bound Raster perspective correction and original geometric tests, plus a directional-light shading core with original tests. **That directional core is not wired into a Raster product-image consumer yet.**
The composition contains66 new test declarations. The intended242-case development selection and49 expected positive CPU markers are **planned, unrun and not yet a frozen recipe contract**. No generated execution receipt or future Cargo artifact is included. Additional preference-compliant auto-Result behavior, stricter TIR acceptance and the real Raster directional consumer are separate private successors and are **not included**.
## Historical evidence and remaining gates
C14 on its own earlier source completed its own combined engine/UI/Render library-test build and inventories. Its original Pause regression passed with both required CPU-positive markers; it then stopped at the new Photographic `finish` error-code assertion: **36 passed,1 failed,108 selected tests unrun**. That historical failure stays preserved. Neither those passes nor C14's build artifacts qualify the new19-path source. C14 did not execute its later Clippy/wasm/lists, full CI, current-source desktop or physical-GPU acceptance.
The independent final V2 review found no remaining actionable **source** blocker; it is not a Rust compiler, test, actual UI/layout or quality result. Next: actual formatting/check, fresh exact source freeze and one cached serial builder run with its own current CargoJSON artifacts and real inventories; original failure/new regressions first, then applicable Clippy/wasm/repository/full gates. A fresh desktop candidate must still be exercised by mouse and keyboard during genuine long final renders, cancellation, editing, export and narrow-window workflows.
The finite64-milestone/1024 provisional-image envelope remains an unmeasured prototype, not a real-time cadence or convergence guarantee. Detailed original scenes and effect-specific numerical/final-image criteria remain required. General glass/indirect transport, transmitted shadows, complete CPU finishing/export and physical GPU acceptance remain open; Raster is schematic. Final admitted dimensions/quality must not be lowered to fund a preview. This checkpoint contains source only: no private machine paths, control/grant data, tokens, logs, local instructions or generated baseline reports.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,456 @@
{
"schema": 1,
"status": "ACTUALLY_ADOPTED_SOURCE_CHECKPOINT_UNFORMATTED_UNCOMPILED_UNRUN",
"source_paths": 19,
"production_draft": 8938,
"production_head": "a077906234972dbbf11e80c54893f85ece97c8b7",
"base": {
"description": "Exact actual C14 post-format protected27 source, not production HEAD alone and not an arbitrary main.",
"post_format_manifest_sha256": "78a09f83c809f11e919c5d6957438389f442d42b05694aebcd39fcdb1786010d",
"public_foundation": "../protected-combined-source/README.md",
"public_formatter_delta": "../next-render-source/c14-postformat-delta.patch",
"foundation_archive_commit": "0175e0c5e618a6308d92818e911e8be1e43fe0c8",
"composition_policy": "Cumulative ALTERNATIVE to all overlapping next-render-source increments and older manual/W2/material/Photographic/Raster packets. Reconstruct the exact C14 post-format base, then apply this cumulative patch once. Never add it on top of its own increments."
},
"authorship": "SolidCraft Render team under 0xlillith: Worker #1 manual-final/engine/UI tests, Worker #2 optical producer/rebind/original numerical cases, Worker #3 material routing/locales and directional core, Designer Photographic corrections, Engineer exact composition and compatibility; independent source/math reviewers retained.",
"original_composition_sha256": "605292e1edb0ffaa2e1830cc3d277f006e6fc218f8ec6fe0dc2bc27b321ffe69",
"adoption_receipt_sha256": "17ad50ebea48bb245ec807b916aded3d716464764b497f4240139019f9fcbfbb",
"adopted_source_manifest_sha256": "5ec1e34bfc91f4e0dc5970191bc34199ad856d2cd998a96f605e01662bf439f8",
"adopted_source_members_checked_by_ROOT": 3571,
"independent_final_source_review_sha256": "68f7e54b47202c5d6bb09ec0d698f9cf81b3071f0497c64713bc77576b8bef73",
"source_review_verdict": "No remaining actionable source blocker found in exact V2. Source closure only; not type, layout, runtime, native or readiness qualification.",
"layer_names_and_hashes": [
{
"label": "manual-final V1+V2 current rebind and ROOT engine compatibility V2",
"manifest_sha256": "d7daad500ebe8b26c2aa365bbea15dc7d7c833490843d11cb319ab619e120a1c"
},
{
"label": "progressive optics + index-matched shells + camera TIR current rebind",
"manifest_sha256": "e1b807f437cc2cbc0c715448ced0e7b5c69752e1369dfd7b5d70ad7e8d9d047e"
},
{
"label": "CPU material routing V2 actual postformat224 to247",
"manifest_sha256": "21f2122f40e8d1d8d984bb8e5cb32d7588baba28fce50e3ee48829174be67c09"
},
{
"label": "progressive UI bridge",
"manifest_sha256": "c966c69eba46862feb5e9b09631b05bc14a89249943aa90d84fae500edc9fcfe"
},
{
"label": "method-specific certification note",
"manifest_sha256": "7e48cf80e3a565a4b81f0237dd66d5e74336bb74645ffed9631e6e8747bbcde1"
},
{
"label": "five manual-final/UI optics tests247to252",
"manifest_sha256": "535019a69e5601034a518e4d76d7ee4ad6605f3748b7dc3487212c9b01a3a5a2"
},
{
"label": "actual-source Raster perspective correction",
"manifest_sha256": "143f709294d383e372fa4871990e288a3db4dc1068218946780656c908366c72"
},
{
"label": "Photographic test contract",
"manifest_sha256": "5d0d064e7d936df9e55e3aabd64e646a5314647e57cb6c51a82e6400b8b18415"
},
{
"label": "Photographic cancellation cursor commit",
"manifest_sha256": "e2c069db6665aefc2c66b655b2c7982bfea288e478c9b8808d8eed440d1f569a"
},
{
"label": "Raster directional core without consumer",
"manifest_sha256": "0fc8fe682940a86a68dfb4befda0a2ff7697c747078290a0358ef6414a1b99d9"
},
{
"label": "Explicit new supported-feature UI adaptation, no original unrelated guard removed",
"manifest_sha256": "aaf2baa59b9ce804d650fecc965ef167e744bf72572ee2d646e7580426c4f72d"
},
{
"label": "UI DTO-stage correction V2: Fixed remainsNone before motor, actual snapshots stay16/0",
"manifest_sha256": "7619dbfeb649aa8c186a7ccb51630550022cdddcc46e53085be7164a7caff62b"
}
],
"source_files": [
{
"path": "assets/locales/pending/cpu-optical-certification.json",
"before_sha256": null,
"candidate_sha256": "8eb71a90f0ac0d497c9bffc922e99871d802942c8c85d4213a66a61ceb79a764",
"before_bytes": 0,
"candidate_bytes": 3542,
"before_newlines": {
"CRLF": 0,
"LF": 0
},
"candidate_newlines": {
"CRLF": 0,
"LF": 29
}
},
{
"path": "assets/locales/pending/cpu-optical-materials.json",
"before_sha256": null,
"candidate_sha256": "5e8a44d004fd2f7d865f244c91a9fb1cf55b4bcea32810f7b9f9eff90c778067",
"before_bytes": 0,
"candidate_bytes": 14577,
"before_newlines": {
"CRLF": 0,
"LF": 0
},
"candidate_newlines": {
"CRLF": 0,
"LF": 110
}
},
{
"path": "crates/engine/src/cmd/render.rs",
"before_sha256": "3c9d5ff6a12618d51e8842c509a314f8e6f4bb56026d77c7c77eb350f3e8d87a",
"candidate_sha256": "c8e029139a5a01efa9196dd45c419fec4a6191f752a86e3b023e2bb0e0fc3ba4",
"before_bytes": 41055,
"candidate_bytes": 41491,
"before_newlines": {
"CRLF": 424,
"LF": 424
},
"candidate_newlines": {
"CRLF": 424,
"LF": 424
},
"test_declarations_before": 4,
"test_declarations_candidate": 4
},
{
"path": "crates/engine/src/cmd/render/jobs.rs",
"before_sha256": "1e38222a2b825264e3cab0308fc73141b2fb4546c06600fe370e7da4dddffbb3",
"candidate_sha256": "ff82cad9aae9f57c81c0da40bd4a7ebbfc390cbbc7fe70ef498eb21a5eb07eea",
"before_bytes": 60448,
"candidate_bytes": 63778,
"before_newlines": {
"CRLF": 0,
"LF": 1188
},
"candidate_newlines": {
"CRLF": 0,
"LF": 1229
},
"test_declarations_before": 8,
"test_declarations_candidate": 8
},
{
"path": "crates/engine/src/render_jobs.rs",
"before_sha256": "b11fd62f42692c5701a08f391c2876de060fde766bf3b3639b3b3eabb2558bd2",
"candidate_sha256": "5655d5f5ae2c80560f9e067ca24424c0561cc5969260ff0343660e4d198909aa",
"before_bytes": 32996,
"candidate_bytes": 42740,
"before_newlines": {
"CRLF": 690,
"LF": 690
},
"candidate_newlines": {
"CRLF": 835,
"LF": 835
},
"test_declarations_before": 10,
"test_declarations_candidate": 13
},
{
"path": "crates/render/src/job/host.rs",
"before_sha256": "617532ab0cbe34a2a6faed82675ca45cf0324aecec3f3c5a8a77c1b69fefef21",
"candidate_sha256": "577295a655ab5a8f6204da2b9d2a6a18d3d2bfeb877993a0dfffdc58462a2551",
"before_bytes": 113761,
"candidate_bytes": 115822,
"before_newlines": {
"CRLF": 0,
"LF": 2535
},
"candidate_newlines": {
"CRLF": 2406,
"LF": 2406
},
"test_declarations_before": 8,
"test_declarations_candidate": 8
},
{
"path": "crates/render/src/job/image.rs",
"before_sha256": "c8c30589c1f25c034115badf3833f7a44f20b03513957ac6f735a3c776a4c2d9",
"candidate_sha256": "0e6fa062eba9d5c0f4899fc92d7e7e667bbad295b35ff6bc8addbc9db7fd6285",
"before_bytes": 34328,
"candidate_bytes": 34526,
"before_newlines": {
"CRLF": 809,
"LF": 809
},
"candidate_newlines": {
"CRLF": 813,
"LF": 813
},
"test_declarations_before": 0,
"test_declarations_candidate": 0
},
{
"path": "crates/render/src/job/image_tests.rs",
"before_sha256": "4630dd681b2655fabbc8d4eb1f5e28b9d668f137ba7f8ba76c5cdfcaa22a37a0",
"candidate_sha256": "4878271823870137cddb4f7f96c61b7e7e17bbfe46488858fcaedd9448c505dd",
"before_bytes": 9587,
"candidate_bytes": 19561,
"before_newlines": {
"CRLF": 204,
"LF": 204
},
"candidate_newlines": {
"CRLF": 402,
"LF": 402
},
"test_declarations_before": 9,
"test_declarations_candidate": 13
},
{
"path": "crates/render/src/job/mod.rs",
"before_sha256": "282667b026f548dbf3167a2a6e06d3526e71065a0826f9a2ee787c726e032b26",
"candidate_sha256": "601391ecc07447e6c65cb3b790c7fe83b839374fa600fc7f79a2668cdf7b3eb0",
"before_bytes": 61840,
"candidate_bytes": 62159,
"before_newlines": {
"CRLF": 0,
"LF": 1472
},
"candidate_newlines": {
"CRLF": 0,
"LF": 1477
},
"test_declarations_before": 4,
"test_declarations_candidate": 4
},
{
"path": "crates/render/src/job/photographic.rs",
"before_sha256": "4baeb099d56c6310c85bd0931276e41a8aba5f5fbd223956dabe2c0b2c00c061",
"candidate_sha256": "74756050d449a3333130e54d54b72aed9de2d54ca535d5b50803c30a9ec35c4b",
"before_bytes": 24993,
"candidate_bytes": 28171,
"before_newlines": {
"CRLF": 0,
"LF": 540
},
"candidate_newlines": {
"CRLF": 0,
"LF": 596
},
"test_declarations_before": 5,
"test_declarations_candidate": 7
},
{
"path": "crates/render/src/raytrace/dielectric_tests.rs",
"before_sha256": "376e5382471300aef16a8b09ec0de756ee597197ab94f5b69ad7a020fad49b70",
"candidate_sha256": "cd6654b63e310fa8c06972060e00c65d056fa691d52c081f2c01fbf41fd2c5cd",
"before_bytes": 33599,
"candidate_bytes": 34054,
"before_newlines": {
"CRLF": 0,
"LF": 730
},
"candidate_newlines": {
"CRLF": 0,
"LF": 735
},
"test_declarations_before": 33,
"test_declarations_candidate": 33
},
{
"path": "crates/render/src/raytrace/image.rs",
"before_sha256": "b12ef2844d8fa08e5bf99f58d09402d420a05321e62bfe38fb7abbdcc970ab82",
"candidate_sha256": "3210cbd9115bd26a5f9bfb7a4e121e79afe2070e17cf02dc8da7feed110ae731",
"before_bytes": 108376,
"candidate_bytes": 112115,
"before_newlines": {
"CRLF": 0,
"LF": 2236
},
"candidate_newlines": {
"CRLF": 0,
"LF": 2298
},
"test_declarations_before": 0,
"test_declarations_candidate": 0
},
{
"path": "crates/render/src/raytrace/image_tests.rs",
"before_sha256": "b3a7ce98e799cdaca59674e029c93845ebb28728df3e742694e16a9b3ca63d41",
"candidate_sha256": "08bbf206da1436f39c225a14529135b47f7381fc3c278ed2c1dd9564c5c5747c",
"before_bytes": 75303,
"candidate_bytes": 114405,
"before_newlines": {
"CRLF": 0,
"LF": 1672
},
"candidate_newlines": {
"CRLF": 0,
"LF": 2414
},
"test_declarations_before": 42,
"test_declarations_candidate": 60
},
{
"path": "crates/render/src/raytrace/primary_aa_tests.rs",
"before_sha256": "4e74a020643d5b5e7dede2e3a2ced39023ac3f40044a207e19a66c11c921b526",
"candidate_sha256": "37ac1993d703bb524d038ec061d621fe0facb44dbdd6d11861d830ae6123d916",
"before_bytes": 78499,
"candidate_bytes": 89108,
"before_newlines": {
"CRLF": 0,
"LF": 1581
},
"candidate_newlines": {
"CRLF": 0,
"LF": 1762
},
"test_declarations_before": 41,
"test_declarations_candidate": 46
},
{
"path": "crates/render/src/studio.rs",
"before_sha256": "c4d24087a01421c528af3debb6eabd7db02f4d799704d967f5df01564ae60b72",
"candidate_sha256": "cf06738ea3c14638eb43ce5c05516a6a95d69ac1658464932bd13e54fb4fedf5",
"before_bytes": 3437,
"candidate_bytes": 11331,
"before_newlines": {
"CRLF": 68,
"LF": 68
},
"candidate_newlines": {
"CRLF": 218,
"LF": 218
},
"test_declarations_before": 2,
"test_declarations_candidate": 8
},
{
"path": "crates/ui-egui/src/render_workspace.rs",
"before_sha256": "a2ddd1f7c61c973d638162c115e58d20cab1060568727b13bea0d1506186a950",
"candidate_sha256": "dd3dc088fdc1080a493eb52f87978fbe8bd36abdfe844f7bbf47b56a986aa7ed",
"before_bytes": 100730,
"candidate_bytes": 109538,
"before_newlines": {
"CRLF": 2242,
"LF": 2242
},
"candidate_newlines": {
"CRLF": 2395,
"LF": 2395
},
"test_declarations_before": 0,
"test_declarations_candidate": 0
},
{
"path": "crates/ui-egui/src/render_workspace_cpu.rs",
"before_sha256": "a1534b73a972fffbfad50b831a00e3166c3d668ae7fb978b1286a3f390f4ade8",
"candidate_sha256": "aa110b13e97c6887f64f449c170c3cdcdf7ba11ec0399372bd0c6134152f158b",
"before_bytes": 27471,
"candidate_bytes": 27938,
"before_newlines": {
"CRLF": 0,
"LF": 616
},
"candidate_newlines": {
"CRLF": 0,
"LF": 623
},
"test_declarations_before": 0,
"test_declarations_candidate": 0
},
{
"path": "crates/ui-egui/src/render_workspace_materials.rs",
"before_sha256": "fec97d2e2ae25068b8024e432457fa6496d659e22a1beb3b312f25eacea5c138",
"candidate_sha256": "e290b9e2404d68524a6a6cb4355b3f11f8bb989e370c869b2a262b1faafa8519",
"before_bytes": 42669,
"candidate_bytes": 49558,
"before_newlines": {
"CRLF": 929,
"LF": 929
},
"candidate_newlines": {
"CRLF": 1045,
"LF": 1045
},
"test_declarations_before": 0,
"test_declarations_candidate": 0
},
{
"path": "crates/ui-egui/src/render_workspace_tests.rs",
"before_sha256": "06e0e59b561b84f358a12961d51eda2cab7f70fe4930fea0959d91c11b589cac",
"candidate_sha256": "edc06b2999b190b85495db5496d68801f77f370a7c8e2763c25d7610841a085b",
"before_bytes": 478983,
"candidate_bytes": 543384,
"before_newlines": {
"CRLF": 0,
"LF": 8870
},
"candidate_newlines": {
"CRLF": 0,
"LF": 9939
},
"test_declarations_before": 224,
"test_declarations_candidate": 252
}
],
"patch_sha256": "946351c9a15cf1d42329d9abfed6f6ac8a39a7ff11427337a0ca933d1df259b8",
"patch_bytes": 279580,
"patch_parts": [
{
"path": "candidate-01.patch",
"bytes": 97508,
"sha256": "00fe3f8074c94a0551215b88482e84a1c08d2cc54e11b8ef97b877bece8f2019"
},
{
"path": "candidate-02.patch",
"bytes": 85753,
"sha256": "8777b829d69ea48605d1ea63f68a050038d192d5138ef73e6bf6a00e92046dd7"
},
{
"path": "candidate-03.patch",
"bytes": 96319,
"sha256": "e27e66f25f6eba103c085f8148b5b3e376cc9a00720ed8f1fc78b82d68c7e373"
}
],
"patch_end_of_line_policy": {
"hunks": "LF-normalized unified text; no universal RAW patch replay claim.",
"reconstruction": "Verify before RAW hashes, normalize only CRLF to LF for textual patch replay, reconstruct candidate endpoint line endings as listed, then verify exact candidate RAW bytes and SHA256. Eight endpoints require CRLF restoration. The actual adoption copied verified RAW candidates; it did not git-apply/fuzzy-apply this patch.",
"CRLF_candidate_endpoints": [
"crates/engine/src/cmd/render.rs",
"crates/engine/src/render_jobs.rs",
"crates/render/src/job/host.rs",
"crates/render/src/job/image.rs",
"crates/render/src/job/image_tests.rs",
"crates/render/src/studio.rs",
"crates/ui-egui/src/render_workspace.rs",
"crates/ui-egui/src/render_workspace_materials.rs"
],
"normalized_hunks_reconstructed_and_RAW_endpoint_hash_verified": 105
},
"new_test_declarations": 66,
"UI_declarations": 252,
"UI_original224_RAW_prefix_exact_bytes": 478983,
"planned_development_selection": {
"cases": 242,
"expected_positive_CPU_markers": 49,
"status": "PLANNED_NOT_RUN_NOT_YET_A_FROZEN_RECIPE_CONTRACT"
},
"tests_executed_on_this_source": 0,
"formatter_build_runtime_or_validation_grant_from_this_checkpoint": false,
"historical_C14_outcome_on_its_own_source": {
"passed": 36,
"failed": 1,
"unrun": 108,
"original_Pause_regression": "PASS with both required positive CPU markers",
"transferred_to_this_source": false
},
"excluded_private_successors": [
"Preference-compliant auto-Result behavior",
"Additional strict camera-TIR acceptance",
"Actual Raster directional consumer/wiring"
],
"pending": [
"Actual formatter/check and fresh post-format freeze",
"Sole-builder own current source-bound build/artifact/inventory and applicable tests",
"Clippy/default wasm/repository/full gates",
"Native mouse/keyboard final accumulation/cadence/export/cancellation/input acceptance",
"Detailed-scene convergence and output-envelope/CPU-Raster-GPU parity"
]
}
@@ -0,0 +1,9 @@
# Complete current local Render candidate: C12 source checkpoint
Reviewable WIP against production draft base a077906234972dbbf11e80c54893f85ece97c8b7. The single patch includes all53 modified/new project paths, including existing core corrections, complete Interactive request/identity/cache controls, waiting/input/layout corrections, preview-help translations, GPU failure reasons, progressive Area/LED and the two real light E2E tests. It supersedes manual composition of earlier incremental patches for this exact snapshot; do not apply both.
C12 is still executing on this frozen source. Formatting/check and repository-list precheck completed; no new Clippy, wasm or selected-test result is claimed at publication. Planned native selection is53 cases/35positive CPU markers, not the full repository gate. The real i18n gate internally invokes a release CLI-registry build; an exterior success alone cannot establish registry success when its error is swallowed. Final actual results remain a separate obligation.
This archive is not production registration, a ready PR, an installed-app fix or a validation waiver. The53path manifest contains raw input hashes; the patch is LF-normalized as explained there. No private controller/lease data, logs, executable, user design or profile is included.
CPU nested-glass V2/engine follow-up, EV/HDR two-test follow-up and Raster main perspective fix remain separate proposals and are NOT included. No full glass/GPU/Raster parity is claimed. Fresh current-source desktop build, native pointer/keyboard acceptance, the actual full gate and maintainer process remain pending.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
{
"status": "SOURCE_CHECKPOINT_C12_LIVE_NOT_A_TEST_RESULT",
"base_commit": "a077906234972dbbf11e80c54893f85ece97c8b7",
"sealed_manifest_sha256": "47f0f0e66d3a8c1f524dec3122622602ae5c1197eaa4f9e28f38d2f59fbbc989",
"source_paths": [
"assets/locales/ar.json",
"assets/locales/bs.json",
"assets/locales/cs.json",
"assets/locales/de.json",
"assets/locales/en.json",
"assets/locales/es.json",
"assets/locales/fr.json",
"assets/locales/gl.json",
"assets/locales/he.json",
"assets/locales/hr.json",
"assets/locales/hu.json",
"assets/locales/id.json",
"assets/locales/it.json",
"assets/locales/ja.json",
"assets/locales/ko.json",
"assets/locales/ms.json",
"assets/locales/nb.json",
"assets/locales/nl.json",
"assets/locales/pending/render-interactive.json",
"assets/locales/ro.json",
"assets/locales/ru.json",
"assets/locales/sr.json",
"assets/locales/sv.json",
"assets/locales/tr.json",
"assets/locales/uk.json",
"assets/locales/vi.json",
"assets/locales/zh-TW.json",
"assets/locales/zh.json",
"crates/engine/src/cmd/mod.rs",
"crates/engine/src/cmd/render.rs",
"crates/engine/src/cmd/render/jobs.rs",
"crates/engine/src/cmd/render_ui.rs",
"crates/engine/src/lib.rs",
"crates/engine/src/render_interactive.rs",
"crates/engine/src/render_jobs.rs",
"crates/engine/src/schema/render_ui.rs",
"crates/gpu/src/path/mod.rs",
"crates/render/src/job/host.rs",
"crates/render/src/job/host_tests.rs",
"crates/render/src/job/interactive.rs",
"crates/render/src/job/mod.rs",
"crates/render/src/raytrace/primary_aa_tests.rs",
"crates/ui-egui/src/command_help.rs",
"crates/ui-egui/src/i18n.rs",
"crates/ui-egui/src/render_ui/mod.rs",
"crates/ui-egui/src/render_workspace.rs",
"crates/ui-egui/src/render_workspace_cpu.rs",
"crates/ui-egui/src/render_workspace_interactive.rs",
"crates/ui-egui/src/render_workspace_ribbon.rs",
"crates/ui-egui/src/render_workspace_tests.rs",
"docs/render-cpu-progressive.md",
"docs/render-interactive.md",
"docs/render-settings.md"
],
"raw_sha256": {
"assets/locales/ar.json": "c9550c904e826c15d0166c03e1074e0d2a9ee530aa1e1e4ba94349cf68276dfd",
"assets/locales/bs.json": "73e5dbe7663db0b237a279ccbeae3c36b0b0ad7ec2c92dd1b58508532c19947c",
"assets/locales/cs.json": "a65d8c8e08fc20e2b79215bc120171fd8b432475d50cd0efc20f002a83fdbcee",
"assets/locales/de.json": "d4fc46818fc98cf705c5dca29d733dde5683fad8904b5edb008f45161103f2ba",
"assets/locales/en.json": "65a3f17a323a5230201e2466f8dbebed40e600d43ff247ec45aab37972ed2004",
"assets/locales/es.json": "67ec9a1ac8648b528aac00e7663d79869b837e4c880b2b4562b265497e511c72",
"assets/locales/fr.json": "f4cdf8a71df33533cf22bf32a0efad5e1f7300e4d3e6fc41a8d18ba0e087e0c3",
"assets/locales/gl.json": "5a70c152291171719e731de794c3dda9c59d01c8ce5c203504220443fa59bb80",
"assets/locales/he.json": "c26fa65f9339b2d8c7242ead3238fa4263753db62e76cdd14da37dbe842b5d3a",
"assets/locales/hr.json": "6639a12b83a0f40aca3f3703f5da02aac299d0b141aff3293cde412052a105dc",
"assets/locales/hu.json": "817d8b2d94cc6e51fdd4fcf5ede8cdcfb54b950efe7e81553486025c112651da",
"assets/locales/id.json": "7879d92fd882349e59e041a523f4c1fff91d8658b55ee8af22d158d498dbfac0",
"assets/locales/it.json": "df6df32fdc8c8c5fc36bfd7d5917749be56f3d52321eea2a59b59b6c1aeae5c2",
"assets/locales/ja.json": "69328222757966f0f23129630e31f34fcdb3e281ec7beecfe3bf1438e028ad13",
"assets/locales/ko.json": "4074ac6c38c9c1df45dc5c82479af9cdf9cd496162b576b5691a7746e0210815",
"assets/locales/ms.json": "60cc04a79c914571f8940d5ba7633cddb0c788ccb1444c2707e715bc534388cd",
"assets/locales/nb.json": "ddc114aa7ea0ae9c9bbfa001e96e0ec4baf09bf1f6a39e5f20cfedf34c7a1909",
"assets/locales/nl.json": "f9ff26d3451d8db96c7b6b587e21ed0f97c2c60ef1155f01959091f0e77edb1c",
"assets/locales/pending/render-interactive.json": "4502966fe59189042d2420defa75091036599c0cb14d573027eb76fca1511a63",
"assets/locales/ro.json": "ed570d60378b5f09f922c7351d536c65124679396f0ed85f209c57c6cf9a23d5",
"assets/locales/ru.json": "f84e64e5bd48f64e186087d40225fe3196d3d17ddccde666432ec25f6918b37f",
"assets/locales/sr.json": "e07a4c02f20731ce701767f6dc792a5004ba69eb24e46a6b9257c9020649fd68",
"assets/locales/sv.json": "cba544a487b218cf76b05e14e5edcd095f36e3553cdee3ca94d28b55f0db66b6",
"assets/locales/tr.json": "4e7d97b67847a8fe6ef9242165d092308f73d4776257f07a10f7fc601007d745",
"assets/locales/uk.json": "45449f7373f565f517e50fb67faa826b9885c6b154cd814e5caeadd6e96afc48",
"assets/locales/vi.json": "44a7746524e1427d004aedab346ba4459015e82589f447152608d174a5b87294",
"assets/locales/zh-TW.json": "b533860b90a8026a50af1696374a10786107b20947d4c39c9df2e22b2811d9d1",
"assets/locales/zh.json": "a7863f8717e6fdf6ebc7129d49a7366018cb9c19ea34610c465beeafe7d627e5",
"crates/engine/src/cmd/mod.rs": "ee2a6307c9bc27436ab1d381e1994f44feb1336055d8f9c0bda9dcaa23cb4ebb",
"crates/engine/src/cmd/render.rs": "63478b87010e4232d524c478ee27517a71b3c5d6059f5facb49cba428ef0aa6b",
"crates/engine/src/cmd/render/jobs.rs": "1cb96c6189fbbf9b904579d6c230aff004004249fcd89a86c3a82d87dc41bd68",
"crates/engine/src/cmd/render_ui.rs": "45d0f233b6788de8f28037cc9ea4447aed19405334f6648dc60943c24672d020",
"crates/engine/src/lib.rs": "3e1109f30b89552f7114276b5ca0fd5d335f845132bca798a091ce7d1a917701",
"crates/engine/src/render_interactive.rs": "2b404d5ec53cf3a4e953c842cc3238c90604dc2dc8b3e7582006d5f6e779ad36",
"crates/engine/src/render_jobs.rs": "b11fd62f42692c5701a08f391c2876de060fde766bf3b3639b3b3eabb2558bd2",
"crates/engine/src/schema/render_ui.rs": "80793c893f50e4dc2f785ff5dd7652c1ef984a4f307e9225a6196203812b4653",
"crates/gpu/src/path/mod.rs": "56eb1fe9e358d08377e37ec28628463497faaae4365a3890d289ba94a66b1777",
"crates/render/src/job/host.rs": "7d2e248f8c7d6586f18d8b9840da9074514ed4672bc8a8845170a805b1bf9315",
"crates/render/src/job/host_tests.rs": "3ed03799be1af84527642893847666a81d79976a3b58cc9551e4c0675676ccd4",
"crates/render/src/job/interactive.rs": "ccc8aaa5fc76038b39e8829f87b0888ccd92816567ea8b3d83778201364a4cce",
"crates/render/src/job/mod.rs": "03b8cb130fd848a64d30048ecdbfea785e9144827336eb8859473293076bc7ee",
"crates/render/src/raytrace/primary_aa_tests.rs": "0b5002bbe5b53d7059d7d1602af559b119d44549f0a19e0fa880c71ce5b9db18",
"crates/ui-egui/src/command_help.rs": "861f005ae58c4d685ef8e2d30e372454607755a3eea3854b37f33147758930ba",
"crates/ui-egui/src/i18n.rs": "39cea02cfac3a21f8727f0a2b3b1ffafb27f7715ce80e60599ec395807b4cf9a",
"crates/ui-egui/src/render_ui/mod.rs": "028173a06e671099b0f01f093110e58270b015d3afb6379abdfaf3db0b4ad53f",
"crates/ui-egui/src/render_workspace.rs": "46970f79648100ffd2676609ff5d9093a317b421aa39f28163c8883dd802d151",
"crates/ui-egui/src/render_workspace_cpu.rs": "a1534b73a972fffbfad50b831a00e3166c3d668ae7fb978b1286a3f390f4ade8",
"crates/ui-egui/src/render_workspace_interactive.rs": "2b385ccba4e94aaff6318f70a3669cd8205d786edad502e01e3c1e03f2e43579",
"crates/ui-egui/src/render_workspace_ribbon.rs": "27fc255da6da75976edb4b37928b78ea034c6c579f258b3155f15d43e3237ffb",
"crates/ui-egui/src/render_workspace_tests.rs": "9d88967c9177b90fcf44174f89d7faa003a5a7c8eade73448cd09384e005a22d",
"docs/render-cpu-progressive.md": "0a2bc05d4751234afbab9002de42827807c48bbb71a254823ac31e38cd8fc373",
"docs/render-interactive.md": "14cf4b47d453c6de0f0c835eac63a98b6f8445fdeb7b5c65d3de538b091577a2",
"docs/render-settings.md": "714958b1b3643d7919b6ea5cf0439923948f05aa3991736f42ea0d5902843e72"
},
"patch_sha256": "5cee44aeee7e400e154dde6f46850f2ea32b48f492d5df72b5e7462047c8bd1f",
"line_endings": "Git/unified patch text uses canonical LF. Raw SHA pins document actual Windows input bytes; they are not a promise that checkout line endings are identical after apply.",
"test_results_claimed": false,
"excluded": "Private active validation lock and all controller/lease/log/design data."
}
@@ -0,0 +1,7 @@
# Narrow-card Pause fix - independently source-reviewed, not executed
C13 stopped on the original real UI assertion: at760x700, Pause y380..408 exceeded the card's visible y327..381. The observed native test exited101; overall1case passed,1failed,51did not run. Keep that failure and the original test unchanged.
This one-file source proposal keeps the real action row outside and before the scrollable status, and limits status to remaining inner height. Ready/disabled logic, labels, actual target rectangles and command dispatch stay unchanged. It does not move Nathan's selection toolbar, panels, Results, Studio or gizmos, hide a failed test, raise a minimum-window threshold or crop target rectangles to fake containment. In a card with no remaining height, status is omitted; actions take priority.
Base render_workspace_interactive.rs SHA2562b385ccba4e94aaff6318f70a3669cd8205d786edad502e01e3c1e03f2e43579; candidatefdc1c1cfa2a97671bcbb94d3dae7d3502eb61e9b9d676011c96baa7bddc37970. Engineer and independent Designer source review found no blocker for this narrow change. The213-test source and all assertions remain intact. No formatting, compilation, test execution or new native button coordinates exist for the fix. A new exact-source serial validation remains required; C13 is never relabelled as passed.
@@ -0,0 +1,92 @@
diff --git a/crates/ui-egui/src/render_workspace_interactive.rs b/crates/ui-egui/src/render_workspace_interactive.rs
--- a/crates/ui-egui/src/render_workspace_interactive.rs
+++ b/crates/ui-egui/src/render_workspace_interactive.rs
@@ -520,13 +520,9 @@
|ui| {
ui.set_width(width);
ui.set_max_width(width);
+ ui.set_max_height(height);
ui.set_clip_rect(ui.clip_rect().intersect(bounds));
- egui::ScrollArea::vertical()
- .id_salt("sc_render_interactive_controls_scroll")
- .min_scrolled_height(0.0)
- .max_height(height)
- .auto_shrink([false, true])
- .show(ui, |ui| controls(app, ui));
+ controls(app, ui);
},
);
crate::shell::register_panel(app, "render:interactive-card", Some(card.response.rect.intersect(bounds)), None);
@@ -551,30 +547,7 @@
let dimensions = state.frame.as_ref().map(|frame| (frame.info.width, frame.info.height));
let stale = state.frame.as_ref().is_some_and(|frame| frame.stale);
let final_active = state.final_active;
- ui_kit::text(ui, &label, ui_kit::Text::Inline);
- if running {
- ui_kit::text(ui, app.tr("render.interactive_provisional"), ui_kit::Text::Note("render.interactive_provisional"));
- }
- if let Some(samples) = samples {
- let label = crate::i18n::format("render.interactive_samples", &[("done", &samples.to_string()), ("total", "16")]);
- ui_kit::text(ui, &label, ui_kit::Text::Inline);
- }
- if let Some((width, height)) = dimensions {
- ui_kit::text(ui, &format!("{width} × {height} px"), ui_kit::Text::Inline);
- }
- if stale {
- ui_kit::text(ui, app.tr("render.interactive_stale"), ui_kit::Text::Note("render.interactive_stale"));
- }
- if final_active {
- ui_kit::text(ui, app.tr("render.interactive_final_active"), ui_kit::Text::Note("render.interactive_final_active"));
- }
- if let Some(error) = error {
- ui_kit::text(ui, &error, ui_kit::Text::Error("render.interactive_error"));
- }
let reason = ready(app).err();
- if let Some(reason) = &reason {
- ui_kit::text(ui, reason, ui_kit::Text::Note("render.interactive_requires_cpu"));
- }
let mut action = None;
ui.horizontal_wrapped(|ui| {
for (action_value, name, key, enabled) in if !running {
@@ -603,6 +576,41 @@
app.set_status(error, true);
}
ui.ctx().request_repaint();
+ }
+ // Actions remain outside the scroller. In a short free rectangle, status
+ // text may scroll but must not push Pause/Resume/Off below the visible clip.
+ let height = ui.available_height().max(0.0);
+ if height > 0.0 {
+ egui::ScrollArea::vertical()
+ .id_salt("sc_render_interactive_controls_scroll")
+ .min_scrolled_height(0.0)
+ .max_height(height)
+ .auto_shrink([false, true])
+ .show(ui, |ui| {
+ ui_kit::text(ui, &label, ui_kit::Text::Inline);
+ if running {
+ ui_kit::text(ui, app.tr("render.interactive_provisional"), ui_kit::Text::Note("render.interactive_provisional"));
+ }
+ if let Some(samples) = samples {
+ let label = crate::i18n::format("render.interactive_samples", &[("done", &samples.to_string()), ("total", "16")]);
+ ui_kit::text(ui, &label, ui_kit::Text::Inline);
+ }
+ if let Some((width, height)) = dimensions {
+ ui_kit::text(ui, &format!("{width} × {height} px"), ui_kit::Text::Inline);
+ }
+ if stale {
+ ui_kit::text(ui, app.tr("render.interactive_stale"), ui_kit::Text::Note("render.interactive_stale"));
+ }
+ if final_active {
+ ui_kit::text(ui, app.tr("render.interactive_final_active"), ui_kit::Text::Note("render.interactive_final_active"));
+ }
+ if let Some(error) = error {
+ ui_kit::text(ui, &error, ui_kit::Text::Error("render.interactive_error"));
+ }
+ if let Some(reason) = &reason {
+ ui_kit::text(ui, reason, ui_kit::Text::Note("render.interactive_requires_cpu"));
+ }
+ });
}
}
pub(super) fn command_ready(app: &SolveApp) -> Result<(), String> {
@@ -0,0 +1,25 @@
# C16 — fixture defaults and preference-compliant Result navigation
This exact five-file source successor has independent source review (`60695ce7`) and was adopted by verified RAW copy, with3572 source members checked and Git HEAD/index/unrelated WIP preserved. **No C16 runtime PASS is claimed.** At adoption the new source remained unformatted, uncompiled and unrun. Production draft #8938 is still not a ready implementation, installed fix or complete render parity.
## Reconstruct the exact base, then this cumulative change once
1. Reconstruct [Actual19](../actual19-reviewed-source/README.md) and its declared earlier protected27/C14 base.
2. Concatenate the numbered `c15-postformat-delta-*.patch` parts in order, using `c15-postformat-manifest.json`, to reach the exact actual C15 post-format source `2fe4f37371e7376e02ae89a080e51fad82e6f6c564bfe8b8a8075c2951fe1f31`. The manifest gives each part's SHA and the whole concatenated patch SHA; file boundaries are preserved. That bridge describes the12 actual formatter-changed endpoints; before and after payloads were read and checked against the sealed source dictionaries, never inferred from hashes. It is source formatting evidence, not a successful build or tests.
3. Apply `candidate.patch` once against that exact base. It is cumulative and is an **alternative** to separately applying the overlapping defaults252 and autoResultV2 patches. It is not a patch for production HEAD or arbitrary main.
Both patch files use LF-normalized textual hunks. The curated manifests and bindings preserve exact RAW before/candidate hashes, lengths, EOL counts and BOM status. Reconstruct only physical line endings explicitly and verify every final RAW SHA. In the C16 endpoints, `prefs.rs`, `prefs/graphics.rs` and `render_workspace_ribbon.rs` remain CRLF; UI tests and the new pending JSON remain LF. No universal literal RAW patch replay or fuzzy application is claimed. Actual adoption copied verified RAW candidates rather than applying normalized text to an unknown tree.
## Behavior and default change
**Changes a default: yes — successful GUI Start opens Result once; the earlier behavior is kept behind `render_open_result_on_start=false`.** The persisted preference has a Graphics checkbox/help/reset path. A successful GUI Start with accepted JobId registration invokes the existing Result-view command once. False preserves either current view; direct API Start, Check, errors, QueueFull and cache repaint do not acquire an automatic view change. A subsequent explicit Model selection remains respected. This does not replace Nateaux's panels/results implementation or alter final/provisional image/export authority.
The independent fixture correction replaces only four nonexistent `Stage`/`SurfaceBindings.clear` calls in two new tests with existing valid `Default` assignments and explicit empty-state preconditions. It does not change product contracts, original geometry, requests, branch assertions, tolerances or output oracles to fit the code. Corrected UI252 plus the unchanged original6879-byte AutoResult appendix yields UI255. Preferences116→117 and UI252→255 account for four new declarations; two new message IDs have all nine locale values.
## Failed evidence stays failed; source review is not execution
C15 completed formatting/check, then its own combined native-library test build failed with four `E0599` errors in those new fixture setups. **Zero tests and zero CPU markers executed; all242 selected cases remained unrun.** There was no complete qualified three-artifact set or real inventories. Independent terminal closure released the owned run; its raw failure is preserved. This successor does not relabel C15 as a pass, retry its unchanged source or inherit C14 test/build evidence.
The bounded source review found no actionable defect in this exact cumulative five-file packet. Actual formatting, fresh source binding, the sole cached serial builder's own artifacts/inventories/regressions and applicable strict/wasm/repository/full gates are still required before qualification. Native mouse/keyboard Result navigation, genuine manual-final accumulation, long-scene cadence/quality, cancellation and export remain acceptance work. The cooperative Raster navigation cases are not CPU/GPU image-quality proof. No physical GPU execution, additional strict TIR or real Raster directional consumer is enabled here.
Only source patches and curated relative-path bindings are in this folder. Private scripts, machine paths, admission/control/grant data, receipts and logs are excluded. Engineer owns canonical publication and later source-bound validation; this checkpoint grants no execution.
@@ -0,0 +1,166 @@
{
"schema": 1,
"status": "ACTUALLY_ADOPTED_SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN_AT_ADOPTION",
"base": {
"description": "Exact actual C15 post-format source, after the adjacent formatter-only bridge",
"manifest_sha256": "2fe4f37371e7376e02ae89a080e51fad82e6f6c564bfe8b8a8075c2951fe1f31",
"formatter_bridge": "c15-postformat-manifest.json"
},
"files": [
{
"path": "crates/ui-egui/src/prefs.rs",
"before_sha256": "c13838afa66969ab40de6825a252efd0436399a30d331f28724be4e1bfe11264",
"candidate_sha256": "b90ea63d6238bf41cb467a41efcd2b3888196e3be60f2453c635078d0933c97d",
"new_file": false,
"delta_sha256": "552aa58c46f5c737e7dc216822d64a442ad3450e667050509a631dc1a8ea7da0"
},
{
"path": "crates/ui-egui/src/prefs/graphics.rs",
"before_sha256": "6168eff4e462477f57ab1ef496ff5917de0e21531f0acf9793c4798dcbcb9caf",
"candidate_sha256": "e48ddcdf0fb9e203133d1d67c7cbba249ee0082b37d07585e9e54146324a2f64",
"new_file": false,
"delta_sha256": "e9732cb7ea2b1c1a684aadc14402be0bc4f14b87732cafb1063f231a0d938d20"
},
{
"path": "crates/ui-egui/src/render_workspace_ribbon.rs",
"before_sha256": "27fc255da6da75976edb4b37928b78ea034c6c579f258b3155f15d43e3237ffb",
"candidate_sha256": "1f69c036f5b7327eb19b2495c7f3f3a30d2cb90a7f37f6b44980efbb37f06cf3",
"new_file": false,
"delta_sha256": "793d1970a5924e745448a280186d2fea265467a1cd4ac59e9f3014aa71aa5efb"
},
{
"path": "crates/ui-egui/src/render_workspace_tests.rs",
"before_sha256": "5eeb9bb333cad6936dae8bade8c3be0eece2cea7f52d274c6bd59b28c42625d9",
"candidate_sha256": "2acd261a36f2baa0acedad44eed6cf67ad7d6ee895e2197539735a266c36ed3e",
"new_file": false,
"delta_sha256": "d06f36553129868fcca5fb00e40cf1e14035e31d0d43b99c78ceeadcd2229d6c"
},
{
"path": "assets/locales/pending/render-result-on-start.json",
"before_sha256": null,
"candidate_sha256": "5ec777e3517710910dbdff067fcf55739961e2023290a2d673744a96505aa5e5",
"new_file": true,
"delta_sha256": "f69b7c1acebfdd623752a5dc01d7503b642eb6ea96d678d7815e120f0a73409c"
}
],
"raw_endpoints": [
{
"path": "crates/ui-egui/src/prefs.rs",
"before": {
"sha256": "c13838afa66969ab40de6825a252efd0436399a30d331f28724be4e1bfe11264",
"bytes": 377352,
"eol": "CRLF",
"crlf": 6770,
"lf": 6770,
"utf8_bom": false
},
"candidate": {
"sha256": "b90ea63d6238bf41cb467a41efcd2b3888196e3be60f2453c635078d0933c97d",
"bytes": 378635,
"eol": "CRLF",
"crlf": 6790,
"lf": 6790,
"utf8_bom": false
}
},
{
"path": "crates/ui-egui/src/prefs/graphics.rs",
"before": {
"sha256": "6168eff4e462477f57ab1ef496ff5917de0e21531f0acf9793c4798dcbcb9caf",
"bytes": 11761,
"eol": "CRLF",
"crlf": 204,
"lf": 204,
"utf8_bom": false
},
"candidate": {
"sha256": "e48ddcdf0fb9e203133d1d67c7cbba249ee0082b37d07585e9e54146324a2f64",
"bytes": 11877,
"eol": "CRLF",
"crlf": 205,
"lf": 205,
"utf8_bom": false
}
},
{
"path": "crates/ui-egui/src/render_workspace_ribbon.rs",
"before": {
"sha256": "27fc255da6da75976edb4b37928b78ea034c6c579f258b3155f15d43e3237ffb",
"bytes": 13010,
"eol": "CRLF",
"crlf": 285,
"lf": 285,
"utf8_bom": false
},
"candidate": {
"sha256": "1f69c036f5b7327eb19b2495c7f3f3a30d2cb90a7f37f6b44980efbb37f06cf3",
"bytes": 13161,
"eol": "CRLF",
"crlf": 288,
"lf": 288,
"utf8_bom": false
}
},
{
"path": "crates/ui-egui/src/render_workspace_tests.rs",
"before": {
"sha256": "5eeb9bb333cad6936dae8bade8c3be0eece2cea7f52d274c6bd59b28c42625d9",
"bytes": 545234,
"eol": "LF",
"crlf": 0,
"lf": 10030,
"utf8_bom": false
},
"candidate": {
"sha256": "2acd261a36f2baa0acedad44eed6cf67ad7d6ee895e2197539735a266c36ed3e",
"bytes": 552355,
"eol": "LF",
"crlf": 0,
"lf": 10169,
"utf8_bom": false
}
},
{
"path": "assets/locales/pending/render-result-on-start.json",
"before": null,
"candidate": {
"sha256": "5ec777e3517710910dbdff067fcf55739961e2023290a2d673744a96505aa5e5",
"bytes": 2962,
"eol": "LF",
"crlf": 0,
"lf": 38,
"utf8_bom": false
}
}
],
"ui_tests_before": 252,
"ui_tests_candidate": 255,
"prefs_tests_before": 116,
"prefs_tests_candidate": 117,
"added_test_declarations": 4,
"change_default_line": "Changes a default: yes - successful GUI Start opens Result once, old behaviour kept behind preference render_open_result_on_start=false",
"composition": {
"original_autov2_ui_before_sha256": "5eeb9bb333cad6936dae8bade8c3be0eece2cea7f52d274c6bd59b28c42625d9",
"original_autov2_ui_candidate_sha256": "d4338085adb86d040926d7df6e1b3b6188adffd22b0272a428732bc5a1320221",
"corrected_ui252_sha256": "afb3daa7b2663d5b9372fdea7ff83ea0ceb5c35da43af10177a5ea6a0abeaaab",
"auto_result_suffix_bytes": 6879,
"expected_ui255_sha256": "2acd261a36f2baa0acedad44eed6cf67ad7d6ee895e2197539735a266c36ed3e",
"other_candidates_identical": 4,
"default_true_and_opt_off_unchanged": true
},
"predecessor_hashes": {
"defaults252": {
"manifest_sha256": "3c0d8e8d60276a054c48024badfa84ff78619f00ca70f0cb5753b97ff0d68d91",
"proposal_sha256": "68b1fe88bebcb64c72dbe5f1b357de10784db2361fa2add2b0c2ea8a2b9fd642",
"bindings_sha256": "0c8153845adb02dbf9f32d943631bc6e4efaee7960da95902c52d689ea515ad4"
},
"autoResultV2": {
"manifest_sha256": "f993c9fb678565381838c96c54b1cf0a46989baebaaff32b36ddaced8867bb58",
"proposal_sha256": "e71efa5b57deb5869b549b2c2eae69f0b50f79c88b52ba3b351115ef2d5eac3d",
"bindings_sha256": "28bcd0455862a5eb321d1b713cd90fb6002cbe7b2ee2098a26908af5a2878615"
}
},
"composition_policy": "Cumulative five-file successor after C15 formatting, not an additional application of the overlapping defaults252 or autoResultV2 patches. UI255 is corrected UI252 plus the same original6879-byte AutoResult appendix. Other four payloads are unchanged from their reviewed AutoResult predecessors.",
"patch_EOLayout": "candidate.patch contains LF-normalized hunks. prefs.rs, prefs/graphics.rs and render_workspace_ribbon.rs must end in CRLF; UI tests and new pending JSON in LF. Raw hashes/sizes/EOL/BOM metadata are authoritative. No universal literal RAW diff replay claim.",
"runtime_PASS_claimed": false
}
@@ -0,0 +1,876 @@
--- a/crates/ui-egui/src/render_workspace_tests.rs
+++ b/crates/ui-egui/src/render_workspace_tests.rs
@@ -8913,7 +8913,10 @@
#[test]
fn explicit_cpu_recipe_builds_typed_optics_with_independent_linear_units_and_no_design_edit() {
- use solidcraft_engine::render::{job::{DielectricBoundary, RaytraceOptions}, transmission::Transmission};
+ use solidcraft_engine::render::{
+ job::{DielectricBoundary, RaytraceOptions},
+ transmission::Transmission,
+ };
let h = fixture();
let document = h.app.session.doc.clone();
let before = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.is_dirty());
@@ -8985,10 +8988,19 @@
let binding = &mut h.app.render_workspace.view.materials.bindings[0];
match case {
"coat" => binding.coat_enabled = true,
- "maps" => { binding.maps_enabled = true; binding.maps.normal = "authored-normal.png".into(); }
+ "maps" => {
+ binding.maps_enabled = true;
+ binding.maps.normal = "authored-normal.png".into();
+ }
"shadows" => binding.glass.shadows = true,
- "subsurface" => binding.glass.subsurface = Some(solidcraft_engine::render::transmission::subsurface::Subsurface { scattering_per_mm: 0.1, anisotropy: 0.0 }),
- _ => { binding.glass_enabled = false; binding.coat_enabled = true; }
+ "subsurface" => {
+ binding.glass.subsurface =
+ Some(solidcraft_engine::render::transmission::subsurface::Subsurface { scattering_per_mm: 0.1, anisotropy: 0.0 })
+ }
+ _ => {
+ binding.glass_enabled = false;
+ binding.coat_enabled = true;
+ }
}
let intent = format!("{:?}", h.app.render_workspace.view.materials);
let mut request = json!({"settings":{"exposure_ev":0},"sentinel":"retain"});
@@ -9094,96 +9106,110 @@
fn progressive_optics_preserves_identity_and_saved_gpu_conflicts_without_mutation() {
let mut h = fixture();
let document = h.app.session.doc.clone();
- let history = (h.app.session.revision,h.app.session.undo.len(),h.app.session.is_dirty());
- let authored = format!("{:?}",h.app.render_workspace.view.materials.bindings[0]);
+ let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.is_dirty());
+ let authored = format!("{:?}", h.app.render_workspace.view.materials.bindings[0]);
h.app.render_workspace.view.raytrace.progressive = Some(Default::default());
- let progressive_final = runtime_image_request(&h.app,false).unwrap();
+ let progressive_final = runtime_image_request(&h.app, false).unwrap();
let final_options: RaytraceOptions = serde_json::from_value(progressive_final["raytrace"].clone()).unwrap();
final_options.validate().unwrap();
- assert_eq!(final_options.primary_samples,1);
- assert_eq!(final_options.progressive.unwrap().samples_per_pixel,16);
- assert_eq!(final_options.progressive.unwrap().seed,0);
+ assert_eq!(final_options.primary_samples, 1);
+ assert_eq!(final_options.progressive.unwrap().samples_per_pixel, 16);
+ assert_eq!(final_options.progressive.unwrap().seed, 0);
let optics = progressive_final["raytrace"]["dielectrics"].clone();
- assert_eq!(optics.as_array().unwrap().len(),1);
- assert_eq!(optics[0]["body_name"],"Optics");
- assert_eq!(optics[0]["boundary"],"closed_box");
- assert_eq!(optics[0]["ior"],1.5);
+ assert_eq!(optics.as_array().unwrap().len(), 1);
+ assert_eq!(optics[0]["body_name"], "Optics");
+ assert_eq!(optics[0]["boundary"], "closed_box");
+ assert_eq!(optics[0]["ior"], 1.5);
assert!(h.app.render_workspace.view.materials.bindings[0].cpu_optics);
let from_progressive = interactive_request(&h.app).unwrap();
- assert_eq!(from_progressive["raytrace"]["dielectrics"],optics,"preview conversion cannot erase or replace the authored optical material");
+ assert_eq!(from_progressive["raytrace"]["dielectrics"], optics, "preview conversion cannot erase or replace the authored optical material");
assert!(h.app.render_workspace.view.materials.bindings[0].glass_enabled);
h.app.render_workspace.view.raytrace.progressive = None;
let from_fixed = interactive_request(&h.app).unwrap();
- assert_eq!(from_fixed["raytrace"]["dielectrics"],optics,"Fixed editor preview still carries certified optics");
+ assert_eq!(from_fixed["raytrace"]["dielectrics"], optics, "Fixed editor preview still carries certified optics");
let progressive_input: RaytraceOptions = serde_json::from_value(from_progressive["raytrace"].clone()).unwrap();
progressive_input.validate().unwrap();
- assert_eq!(progressive_input.primary_samples,1);
- assert_eq!(progressive_input.progressive.unwrap().samples_per_pixel,16);
- assert_eq!(progressive_input.progressive.unwrap().seed,0);
+ assert_eq!(progressive_input.primary_samples, 1);
+ assert_eq!(progressive_input.progressive.unwrap().samples_per_pixel, 16);
+ assert_eq!(progressive_input.progressive.unwrap().seed, 0);
let fixed_input: RaytraceOptions = serde_json::from_value(from_fixed["raytrace"].clone()).unwrap();
fixed_input.validate().unwrap();
- assert_eq!(fixed_input.primary_samples,1);
- assert!(fixed_input.progressive.is_none(),"the UI Fixed DTO stays Fixed until the engine preview conversion");
- assert!(h.app.render_workspace.view.raytrace.progressive.is_none(),"constructing preview intent cannot change the manual-final editor");
+ assert_eq!(fixed_input.primary_samples, 1);
+ assert!(fixed_input.progressive.is_none(), "the UI Fixed DTO stays Fixed until the engine preview conversion");
+ assert!(h.app.render_workspace.view.raytrace.progressive.is_none(), "constructing preview intent cannot change the manual-final editor");
#[cfg(not(target_arch = "wasm32"))]
- if native_host(&mut h,"progressive_bridge_obsolete_guard_adaptation") {
- use solidcraft_engine::render::job::{JobId,Phase,interactive::VisualRevision};
+ if native_host(&mut h, "progressive_bridge_obsolete_guard_adaptation") {
+ use solidcraft_engine::render::job::{JobId, Phase, interactive::VisualRevision};
use std::time::Duration;
// Same original20x20/one-pixel/50-distance inputs: this is actual
// capture/admission/completion, not a Beer pixel oracle (jitter may miss).
let mut final_params = progressive_final.clone();
final_params["progressive_feedback"] = json!(true);
- let checked = h.app.run("render.preflight",final_params.clone()).unwrap();
- let started = h.app.run("render.start",final_params).unwrap();
+ let checked = h.app.run("render.preflight", final_params.clone()).unwrap();
+ let started = h.app.run("render.start", final_params).unwrap();
let final_id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let final_job = h.app.session.render_job_handle(final_id).unwrap();
let final_status = final_job.wait_timeout(Duration::from_secs(60)).unwrap();
- assert_eq!(final_status.progress.phase,Phase::Completed,"{:?}",final_status.error);
- assert_eq!(serde_json::to_value(&final_status.request).unwrap(),checked["request"]);
- assert_eq!(json!(final_status.request.raytrace),progressive_final["raytrace"]);
+ assert_eq!(final_status.progress.phase, Phase::Completed, "{:?}", final_status.error);
+ assert_eq!(serde_json::to_value(&final_status.request).unwrap(), checked["request"]);
+ assert_eq!(json!(final_status.request.raytrace), progressive_final["raytrace"]);
assert!(!final_status.options.nested_media);
assert!(final_job.protected_preview_report().unwrap().is_some());
let final_bytes = final_job.frame().unwrap().unwrap().png().to_vec();
- for (index,params) in [&from_progressive,&from_fixed].into_iter().enumerate() {
+ for (index, params) in [&from_progressive, &from_fixed].into_iter().enumerate() {
let context = h.app.session.interactive_render().unwrap();
- context.enable(VisualRevision {
- scene_generation:h.app.session.revision,visual_revision:index as u64+1,
- },Duration::ZERO).unwrap();
+ context
+ .enable(VisualRevision { scene_generation: h.app.session.revision, visual_revision: index as u64 + 1 }, Duration::ZERO)
+ .unwrap();
let ticket = context.capture(Duration::from_millis(200)).unwrap().unwrap();
- let snapshot = context.snapshot(&h.app.session,ticket,params).unwrap();
- assert!(snapshot.run().unwrap(),"real optical snapshot must be admitted/adopted");
+ let snapshot = context.snapshot(&h.app.session, ticket, params).unwrap();
+ assert!(snapshot.run().unwrap(), "real optical snapshot must be admitted/adopted");
let job = context.current().unwrap().unwrap();
let status = job.wait_timeout(Duration::from_secs(60)).unwrap();
- assert_eq!(status.progress.phase,Phase::Completed,"{:?}",status.error);
- assert_eq!(status.resolved.executor.as_str(),"cpu-raytrace");
+ assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
+ assert_eq!(status.resolved.executor.as_str(), "cpu-raytrace");
let options = status.request.raytrace.as_ref().unwrap();
- assert_eq!(serde_json::to_value(&options.dielectrics).unwrap(),optics);
- assert_eq!(options.primary_samples,1);
- assert_eq!(options.progressive.unwrap().samples_per_pixel,16);
- assert_eq!(options.progressive.unwrap().seed,0);
- assert_eq!((status.request.width,status.request.height),(1,1));
+ assert_eq!(serde_json::to_value(&options.dielectrics).unwrap(), optics);
+ assert_eq!(options.primary_samples, 1);
+ assert_eq!(options.progressive.unwrap().samples_per_pixel, 16);
+ assert_eq!(options.progressive.unwrap().seed, 0);
+ assert_eq!((status.request.width, status.request.height), (1, 1));
assert!(!status.options.nested_media);
- assert_eq!(job.id(),ticket.job_id());
+ assert_eq!(job.id(), ticket.job_id());
assert!(job.frame().unwrap().is_some());
- assert!(h.app.session.render_job_handle(job.id()).is_err(),"Interactive keeps a separate non-export owner");
+ assert!(h.app.session.render_job_handle(job.id()).is_err(), "Interactive keeps a separate non-export owner");
context.disable().unwrap();
- assert_eq!(h.app.session.render_job_handle(final_id).unwrap().frame().unwrap().unwrap().png(),final_bytes.as_slice());
+ assert_eq!(h.app.session.render_job_handle(final_id).unwrap().frame().unwrap().unwrap().png(), final_bytes.as_slice());
}
}
- assert!(Arc::ptr_eq(&document,&h.app.session.doc));
- assert_eq!(history,(h.app.session.revision,h.app.session.undo.len(),h.app.session.is_dirty()));
- assert_eq!(format!("{:?}",h.app.render_workspace.view.materials.bindings[0]),authored);
+ assert!(Arc::ptr_eq(&document, &h.app.session.doc));
+ assert_eq!(history, (h.app.session.revision, h.app.session.undo.len(), h.app.session.is_dirty()));
+ assert_eq!(format!("{:?}", h.app.render_workspace.view.materials.bindings[0]), authored);
let mut request = wire(&h);
let before = request.clone();
assert!(materials::params(&h.app, &mut request).unwrap_err().contains(h.app.tr("render.cpu_optics_conflict")));
assert_eq!(request, before);
let target = h.app.render_workspace.view.materials.bindings[0].target.stable_target.clone().unwrap();
- h.app.render_workspace.view.draft.optics.set(solidcraft_engine::doc::render_optics::SavedOptics {
- target, optics: solidcraft_engine::doc::render_optics::OpticalMaterial {
- ior: 1.5, absorption_per_mm: [0.0; 3], boundary: solidcraft_engine::doc::render_optics::OpticalBoundary::ClosedMesh,
- thickness_mm: 0.0, shadow_transmission: false, subsurface: None,
- },
- }, false).unwrap();
+ h.app
+ .render_workspace
+ .view
+ .draft
+ .optics
+ .set(
+ solidcraft_engine::doc::render_optics::SavedOptics {
+ target,
+ optics: solidcraft_engine::doc::render_optics::OpticalMaterial {
+ ior: 1.5,
+ absorption_per_mm: [0.0; 3],
+ boundary: solidcraft_engine::doc::render_optics::OpticalBoundary::ClosedMesh,
+ thickness_mm: 0.0,
+ shadow_transmission: false,
+ subsurface: None,
+ },
+ },
+ false,
+ )
+ .unwrap();
assert!(runtime_image_request(&h.app, false).is_err());
assert_eq!(h.app.render_workspace.view.draft.optics.len(), 1);
}
@@ -9206,7 +9232,8 @@
use solidcraft_engine::render::job::CpuHost;
let host = CpuHost::for_platform().unwrap();
let available = host.kind() == solidcraft_engine::render::job::HostKind::NativeWorker
- && host.is_available() && host.validate_integrator(Integrator::Raytrace).is_ok();
+ && host.is_available()
+ && host.validate_integrator(Integrator::Raytrace).is_ok();
h.app.session.set_render_job_host(Some(host.clone())).unwrap();
h.app.render_workspace.image_host = Some(host);
if !available {
@@ -9250,7 +9277,9 @@
#[test]
fn native_library_box_reaches_same_cpu_check_start_and_real_beer_pixels() {
let mut h = fixture();
- if !native_host(&mut h, "library_box") { return; }
+ if !native_host(&mut h, "library_box") {
+ return;
+ }
// Explicit author edit isolates Beer: index-matched boundaries have zero
// Fresnel reflection, 4 mm actual distance yields [.98,.99,1].
let glass = &mut h.app.render_workspace.view.materials.bindings[0].glass;
@@ -9263,7 +9292,9 @@
#[test]
fn native_cpu_optics_admission_preserves_opacity_and_refuses_false_thin_certificate() {
let mut h = fixture();
- if !native_host(&mut h, "opacity_and_certificate") { return; }
+ if !native_host(&mut h, "opacity_and_certificate") {
+ return;
+ }
h.app.render_workspace.view.materials.bindings[0].glass.boundary = materials::Boundary::Thin;
let request = image_params(&h.app).unwrap();
for command in ["render.preflight", "render.start"] {
@@ -9288,7 +9319,9 @@
#[test]
fn native_thin_library_recipe_uses_actual_imported_planar_certificate_and_virtual_distance() {
let mut h = fixture();
- if !native_host(&mut h, "library_thin") { return; }
+ if !native_host(&mut h, "library_thin") {
+ return;
+ }
let path = h.dir.join("original-optical-sheet.stl");
// Original one-triangle geometry, not a box proxy or commercial fixture.
std::fs::write(&path, b"solid OriginalSheet\nfacet normal 0 0 1\nouter loop\nvertex -20 -20 2\nvertex 20 -20 2\nvertex 0 20 2\nendloop\nendfacet\nendsolid OriginalSheet\n").unwrap();
@@ -9313,7 +9346,9 @@
#[test]
fn native_library_nested_opt_in_preserves_real_child_medium_and_default_refusal() {
let mut h = fixture();
- if !native_host(&mut h, "library_nested") { return; }
+ if !native_host(&mut h, "library_nested") {
+ return;
+ }
h.app.run("solid.box", json!({"length":10,"width":10,"height":2,"center":[0,0,2],"operation":"new","body_name":"Inner"})).unwrap();
h.app.render_workspace.view.materials.bindings.clear();
bind_recipe(&mut h, "Optics", "starter.glass.low-iron-clear", materials::Boundary::Box);
@@ -9340,7 +9375,9 @@
#[test]
fn native_cpu_optics_refuses_face_alpha_and_general_closed_geometry_without_repairs() {
let mut h = fixture();
- if !native_host(&mut h, "face_alpha_and_sphere") { return; }
+ if !native_host(&mut h, "face_alpha_and_sphere") {
+ return;
+ }
h.app.run("appearance.assign", json!({"body":"Optics","faces":[[0,0,4]],"color":"#abcdef","opacity":0.5})).unwrap();
let mut binding = h.app.render_workspace.view.materials.bindings[0].clone();
materials::revalidate(&mut h.app, &mut binding).unwrap();
@@ -9399,7 +9436,9 @@
fn native_original_library_ior_and_hdr_survive_check_start_and_captured_file_change() {
use solidcraft_engine::render::job::{JobId, Phase};
let mut h = fixture();
- if !native_host(&mut h, "library_ior_hdr") { return; }
+ if !native_host(&mut h, "library_ior_hdr") {
+ return;
+ }
let path = h.dir.join("original-optical-white.hdr");
let bytes = original_hdr_bytes(128, true);
std::fs::write(&path, &bytes).unwrap();
@@ -9432,9 +9471,16 @@
#[test]
fn native_eight_optical_library_targets_admit_without_raising_the_ninth_target_limit() {
let mut h = fixture();
- if !native_host(&mut h, "eight_library_targets") { return; }
+ if !native_host(&mut h, "eight_library_targets") {
+ return;
+ }
for index in 1..=8 {
- h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[30*index,0,0],"body_name":format!("Optics{index}"),"operation":"new"})).unwrap();
+ h.app
+ .run(
+ "solid.box",
+ json!({"length":2,"width":2,"height":2,"corner":[30*index,0,0],"body_name":format!("Optics{index}"),"operation":"new"}),
+ )
+ .unwrap();
}
h.app.render_workspace.view.materials.bindings.clear();
bind_recipe(&mut h, "Optics", "starter.glass.low-iron-clear", materials::Boundary::Box);
@@ -9482,9 +9528,16 @@
for language in ["en", "es", "de", "ko", "ar", "he", "ja", "vi", "zh"] {
let locale = crate::i18n::Locale::for_language(language);
for key in [
- "render.cpu_optics_boundary", "render.cpu_optics_conflict", "render.cpu_optics_enable",
- "render.cpu_optics_explicit", "render.cpu_optics_fixed", "render.cpu_optics_help",
- "render.cpu_optics_limit", "render.cpu_optics_request", "render.cpu_optics_saved", "render.cpu_optics_unsupported",
+ "render.cpu_optics_boundary",
+ "render.cpu_optics_conflict",
+ "render.cpu_optics_enable",
+ "render.cpu_optics_explicit",
+ "render.cpu_optics_fixed",
+ "render.cpu_optics_help",
+ "render.cpu_optics_limit",
+ "render.cpu_optics_request",
+ "render.cpu_optics_saved",
+ "render.cpu_optics_unsupported",
] {
assert_ne!(locale.text(key), key, "{language}: {key}");
assert!(!locale.text(key).is_empty(), "{language}: {key}");
@@ -9494,7 +9547,11 @@
fn editor_frame(h: &mut Harness, events: Vec<egui::Event>) {
let mut output = h.ctx.run_ui(
- egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 2400.0))), events, ..Default::default() },
+ egui::RawInput {
+ screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 2400.0))),
+ events,
+ ..Default::default()
+ },
|ui| {
ui.set_width(420.0);
materials::selection_controls(&mut h.app, ui, Some("Optics"));
@@ -9506,9 +9563,13 @@
fn editor_click(h: &mut Harness, id: &str) {
let at = crate::scenario::rect_of(id).unwrap().center();
for pressed in [true, false] {
- editor_frame(h, vec![egui::Event::PointerMoved(at), egui::Event::PointerButton {
- pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE,
- }]);
+ editor_frame(
+ h,
+ vec![
+ egui::Event::PointerMoved(at),
+ egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
+ ],
+ );
}
editor_frame(h, vec![]);
}
@@ -9568,16 +9629,12 @@
#[cfg(not(target_arch = "wasm32"))]
mod manual_final_cpu_feedback_tests {
use super::*;
- use solidcraft_engine::render::job::{
- CpuHost, CpuProgressiveOptions, HostKind, JobHandle, PartialPreviewPlan,
- PartialPreviewProjection, Phase,
- };
+ use solidcraft_engine::render::job::{CpuHost, CpuProgressiveOptions, HostKind, JobHandle, PartialPreviewPlan, PartialPreviewProjection, Phase};
fn fixture(width: u32, height: u32, samples: u32, background: [u8; 3], case: &str) -> Option<Harness> {
let mut h = Harness::new();
let host = CpuHost::for_platform().unwrap();
- let available = host.kind() == HostKind::NativeWorker && host.is_available()
- && host.validate_integrator(Integrator::Raytrace).is_ok();
+ let available = host.kind() == HostKind::NativeWorker && host.is_available() && host.validate_integrator(Integrator::Raytrace).is_ok();
h.app.session.set_render_job_host(Some(host.clone())).unwrap();
h.app.render_workspace.image_host = Some(host);
h.app.ui.tab = "RENDER".into();
@@ -9591,7 +9648,9 @@
view.draft.exposure_ev = 0.0;
view.transparent = false;
view.raytrace = RaytraceOptions {
- primary_samples: 1, lights: Some(Vec::new()), shadows: false,
+ primary_samples: 1,
+ lights: Some(Vec::new()),
+ shadows: false,
progressive: Some(CpuProgressiveOptions { samples_per_pixel: samples, seed: 83 }),
..Default::default()
};
@@ -9602,8 +9661,8 @@
"raytrace":{"primary_samples":1,"lights":[],"shadows":false,
"progressive":{"samples_per_pixel":samples,"seed":83}}});
assert!(image_params(&h.app).is_err());
- assert!(h.app.run("render.preflight",wire.clone()).is_err());
- assert!(h.app.run("render.start",wire).is_err());
+ assert!(h.app.run("render.preflight", wire.clone()).is_err());
+ assert!(h.app.run("render.start", wire).is_err());
assert!(h.app.render_workspace.view.job.is_none());
assert!(h.app.render_workspace.final_partial.is_none());
eprintln!("MANUAL_FINAL_CPU_BRANCH case={case} cpu_supported=false unsupported_refusal_asserted=true");
@@ -9614,12 +9673,12 @@
}
fn actual_final(h: &mut Harness) -> JobHandle {
- ribbon::perform_job(&mut h.app,"render.start").unwrap();
+ ribbon::perform_job(&mut h.app, "render.start").unwrap();
let job = h.app.render_workspace.view.job.unwrap();
let handle = h.app.session.render_job_handle(job).unwrap();
let status = handle.wait_timeout(Duration::from_secs(60)).unwrap();
- assert_eq!(status.progress.phase,Phase::Completed,"{:?}",status.error);
- assert_eq!(status.resolved.executor.as_str(),"cpu-raytrace");
+ assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
+ assert_eq!(status.resolved.executor.as_str(), "cpu-raytrace");
assert!(handle.frame().unwrap().is_some());
assert!(handle.protected_preview_report().unwrap().is_some());
handle
@@ -9629,170 +9688,173 @@
// Derive bounded display pixels from an actual completed native final, then
// author an explicit source-frontier descriptor to exercise the UI policy.
// No JobHandle, host status, PreviewInfo or GPU executor is fabricated.
- fn install_policy_partial(
- h: &mut Harness, handle: &JobHandle, passes: u32, prefix: u64, sequence: u64,
- ) -> interactive::PartialSampling {
- use sha2::{Digest,Sha256};
+ fn install_policy_partial(h: &mut Harness, handle: &JobHandle, passes: u32, prefix: u64, sequence: u64) -> interactive::PartialSampling {
+ use sha2::{Digest, Sha256};
let status = handle.status().unwrap();
- assert_eq!(status.progress.phase,Phase::Completed);
+ assert_eq!(status.progress.phase, Phase::Completed);
let frame = handle.frame().unwrap().unwrap();
let source = image::load_from_memory(frame.png()).unwrap().to_rgba8();
- let plan = PartialPreviewPlan::new(status.request.width,status.request.height).unwrap();
+ let plan = PartialPreviewPlan::new(status.request.width, status.request.height).unwrap();
let mut pixels = Vec::new();
for index in 0..usize::try_from(plan.pixels()).unwrap() {
let source_index = plan.source_index(index).unwrap();
let count = passes + u32::from((source_index as u64) < prefix);
if count == 0 {
- pixels.extend_from_slice(&[0,0,0,0]);
+ pixels.extend_from_slice(&[0, 0, 0, 0]);
} else {
let x = source_index % status.request.width as usize;
let y = source_index / status.request.width as usize;
- pixels.extend_from_slice(&source.get_pixel(x as u32,y as u32).0);
+ pixels.extend_from_slice(&source.get_pixel(x as u32, y as u32).0);
}
}
- let png = super::partial_png_contract_encode(plan.width(),plan.height(),pixels);
+ let png = super::partial_png_contract_encode(plan.width(), plan.height(), pixels);
let mut frame_info = frame.info.clone();
frame_info.width = plan.width();
frame_info.height = plan.height();
frame_info.png_bytes = png.len() as u64;
- frame_info.png_sha256 = format!("{:x}",Sha256::digest(&png));
+ frame_info.png_sha256 = format!("{:x}", Sha256::digest(&png));
let info = PartialPreviewInfo {
- frame:frame_info,sequence,source_width:status.request.width,source_height:status.request.height,
- completed_passes:passes,updated_prefix:prefix,
- projection:PartialPreviewProjection::TopLeftRepresentativeV1,
+ frame: frame_info,
+ sequence,
+ source_width: status.request.width,
+ source_height: status.request.height,
+ completed_passes: passes,
+ updated_prefix: prefix,
+ projection: PartialPreviewProjection::TopLeftRepresentativeV1,
};
- let covered = vec![0;usize::try_from(plan.pixels()).unwrap()]; // committed misses have zero hit coverage
+ let covered = vec![0; usize::try_from(plan.pixels()).unwrap()]; // committed misses have zero hit coverage
let expected = interactive::PartialExpectation {
- job:handle.id(),scene_generation:status.scene_generation,request:&status.request,
- final_dimensions:[status.request.width,status.request.height],
+ job: handle.id(),
+ scene_generation: status.scene_generation,
+ request: &status.request,
+ final_dimensions: [status.request.width, status.request.height],
};
- let sampling = interactive::validate_partial_metadata(&expected,&info,png.len(),covered.len(),None).unwrap().unwrap();
- let image = interactive::decode_partial_image(
- &status.request,&info,&png,&covered,sampling,h.ctx.input(|input|input.max_texture_side),
- ).unwrap();
- let texture = h.ctx.load_texture("manual_final_policy_partial",image,egui::TextureOptions::NEAREST);
- h.app.render_workspace.final_partial = Some(FinalPartialImage { info,sampling,image:Ok(texture) });
+ let sampling = interactive::validate_partial_metadata(&expected, &info, png.len(), covered.len(), None).unwrap().unwrap();
+ let image =
+ interactive::decode_partial_image(&status.request, &info, &png, &covered, sampling, h.ctx.input(|input| input.max_texture_side)).unwrap();
+ let texture = h.ctx.load_texture("manual_final_policy_partial", image, egui::TextureOptions::NEAREST);
+ h.app.render_workspace.final_partial = Some(FinalPartialImage { info, sampling, image: Ok(texture) });
sampling
}
#[test]
fn native_manual_final_gui_opt_in_preserves_request_and_promotes_only_real_final() {
- let Some(mut h) = fixture(13,7,9,[17,89,203],"gui_opt_in") else { return };
+ let Some(mut h) = fixture(13, 7, 9, [17, 89, 203], "gui_opt_in") else { return };
let document = h.app.session.doc.clone();
- let before = (h.app.session.revision,h.app.session.undo.len(),h.app.session.is_dirty());
+ let before = (h.app.session.revision, h.app.session.undo.len(), h.app.session.is_dirty());
let stored = setups::snapshot(&h.app).unwrap();
assert!(serde_json::to_value(&stored).unwrap().get("progressive_feedback").is_none());
let offline = image_request(&h.app).unwrap();
assert!(offline.get("progressive_feedback").is_none());
let request = image_params(&h.app).unwrap();
- assert_eq!(request["progressive_feedback"],true);
+ assert_eq!(request["progressive_feedback"], true);
h.app.render_workspace.view.raytrace.progressive = None;
assert!(image_params(&h.app).unwrap().get("progressive_feedback").is_none());
- h.app.render_workspace.view.raytrace.progressive = Some(CpuProgressiveOptions { samples_per_pixel:9,seed:83 });
- let checked = h.app.session.execute("render.preflight",&request).unwrap();
- ribbon::perform_job(&mut h.app,"render.preflight").unwrap();
- assert_eq!(h.app.render_workspace.view.cpu.checked.as_ref().unwrap().request,request);
+ h.app.render_workspace.view.raytrace.progressive = Some(CpuProgressiveOptions { samples_per_pixel: 9, seed: 83 });
+ let checked = h.app.session.execute("render.preflight", &request).unwrap();
+ ribbon::perform_job(&mut h.app, "render.preflight").unwrap();
+ assert_eq!(h.app.render_workspace.view.cpu.checked.as_ref().unwrap().request, request);
let handle = actual_final(&mut h);
let status = handle.status().unwrap();
- assert_eq!(serde_json::to_value(&status.request).unwrap(),checked["request"]);
- assert_eq!((status.request.width,status.request.height),(13,7));
+ assert_eq!(serde_json::to_value(&status.request).unwrap(), checked["request"]);
+ assert_eq!((status.request.width, status.request.height), (13, 7));
let progressive = status.request.raytrace.as_ref().unwrap().progressive.unwrap();
- assert_eq!((progressive.samples_per_pixel,progressive.seed),(9,83));
+ assert_eq!((progressive.samples_per_pixel, progressive.seed), (9, 83));
let image = image::load_from_memory(handle.frame().unwrap().unwrap().png()).unwrap().to_rgba8();
- assert!(image.pixels().all(|pixel|pixel.0 == [17,89,203,255]));
+ assert!(image.pixels().all(|pixel| pixel.0 == [17, 89, 203, 255]));
let ctx = h.ctx.clone();
- cache_render_images(&mut h.app,&ctx);
- assert_eq!(h.app.render_workspace.view.completed,Some(handle.id()));
+ cache_render_images(&mut h.app, &ctx);
+ assert_eq!(h.app.render_workspace.view.completed, Some(handle.id()));
assert!(h.app.render_workspace.final_partial.is_none());
let display = displayed_image(&h.app).unwrap();
assert!(!display.provisional());
assert!(display.preview.is_none() && display.partial.is_none());
- assert_eq!(display.logical_size,[13,7]);
- assert_eq!(display.info.job,handle.id());
- assert_eq!(setups::snapshot(&h.app).unwrap(),stored);
- assert!(Arc::ptr_eq(&document,&h.app.session.doc));
- assert_eq!(before,(h.app.session.revision,h.app.session.undo.len(),h.app.session.is_dirty()));
+ assert_eq!(display.logical_size, [13, 7]);
+ assert_eq!(display.info.job, handle.id());
+ assert_eq!(setups::snapshot(&h.app).unwrap(), stored);
+ assert!(Arc::ptr_eq(&document, &h.app.session.doc));
+ assert_eq!(before, (h.app.session.revision, h.app.session.undo.len(), h.app.session.is_dirty()));
}
#[test]
fn native_final_partial_policy_keeps_global_unknown_full_framing_and_real_final_priority() {
- let Some(mut h) = fixture(1025,1,3,[17,89,203],"partial_policy") else { return };
+ let Some(mut h) = fixture(1025, 1, 3, [17, 89, 203], "partial_policy") else { return };
let handle = actual_final(&mut h);
assert!(h.app.render_workspace.view.completed.is_none());
// The top-left 1024 representatives are known, but source pixel1024 is not.
- let sampling = install_policy_partial(&mut h,&handle,0,1024,1);
+ let sampling = install_policy_partial(&mut h, &handle, 0, 1024, 1);
assert!(sampling.source_unknown());
- assert_eq!(sampling.sample_range(),(0,1));
- assert_eq!(sampling.sampled_source_pixels(),1024);
+ assert_eq!(sampling.sample_range(), (0, 1));
+ assert_eq!(sampling.sampled_source_pixels(), 1024);
let display = displayed_image(&h.app).unwrap();
assert!(display.provisional() && display.partial.is_some() && display.preview.is_none());
- assert_eq!(display.logical_size,[1025,1]);
- assert_eq!((display.info.width,display.info.height),(1024,1));
- assert_eq!(display.image.as_ref().unwrap().size(),[1024,1]);
+ assert_eq!(display.logical_size, [1025, 1]);
+ assert_eq!((display.info.width, display.info.height), (1024, 1));
+ assert_eq!(display.image.as_ref().unwrap().size(), [1024, 1]);
assert!(reduced_display(&h.app));
- let camera = Camera { distance:37.0,..Default::default() };
+ let camera = Camera { distance: 37.0, ..Default::default() };
h.app.render_workspace.view.result_camera = camera;
h.app.render_workspace.view.result_scale = ResultScale::Free;
h.app.render_workspace.view.result_job = Some(handle.id());
let report = handle.protected_preview_report().unwrap();
let ctx = h.ctx.clone();
- cache_render_images(&mut h.app,&ctx);
- assert_eq!(h.app.render_workspace.view.completed,Some(handle.id()));
+ cache_render_images(&mut h.app, &ctx);
+ assert_eq!(h.app.render_workspace.view.completed, Some(handle.id()));
assert!(h.app.render_workspace.final_partial.is_none());
let display = displayed_image(&h.app).unwrap();
assert!(!display.provisional());
- assert_eq!(display.logical_size,[1025,1]);
+ assert_eq!(display.logical_size, [1025, 1]);
assert!(!reduced_display(&h.app));
- assert_eq!(h.app.render_workspace.view.result_camera,camera);
- assert_eq!(h.app.render_workspace.view.result_scale,ResultScale::Free);
+ assert_eq!(h.app.render_workspace.view.result_camera, camera);
+ assert_eq!(h.app.render_workspace.view.result_scale, ResultScale::Free);
invalidate_images(&mut h.app);
assert!(h.app.render_workspace.final_partial.is_none());
- assert_eq!(handle.protected_preview_report().unwrap(),report,"presentation invalidation cannot reset the actual ledger");
+ assert_eq!(handle.protected_preview_report().unwrap(), report, "presentation invalidation cannot reset the actual ledger");
}
#[test]
fn native_late_complete_policy_preserves_retained_export_then_reconciles_actual_frame() {
- let Some(mut h) = fixture(13,7,9,[17,89,203],"late_complete") else { return };
+ let Some(mut h) = fixture(13, 7, 9, [17, 89, 203], "late_complete") else { return };
let first = actual_final(&mut h);
let ctx = h.ctx.clone();
- cache_render_images(&mut h.app,&ctx);
+ cache_render_images(&mut h.app, &ctx);
let first_bytes = first.frame().unwrap().unwrap().png().to_vec();
- h.app.render_workspace.view.draft.background = [91,33,187];
+ h.app.render_workspace.view.draft.background = [91, 33, 187];
let second = actual_final(&mut h);
- assert_eq!(h.app.render_workspace.view.completed,Some(first.id()));
- install_policy_partial(&mut h,&second,0,5,1);
+ assert_eq!(h.app.render_workspace.view.completed, Some(first.id()));
+ install_policy_partial(&mut h, &second, 0, 5, 1);
assert!(displayed_image(&h.app).unwrap().provisional());
let old_path = h.dir.join("manual-final-retained.png");
let export_job = h.app.render_workspace.view.completed.unwrap();
- h.app.run("render.export",json!({"job":export_job,"path":old_path})).unwrap();
- assert_eq!(std::fs::read(&old_path).unwrap(),first_bytes);
+ h.app.run("render.export", json!({"job":export_job,"path":old_path})).unwrap();
+ assert_eq!(std::fs::read(&old_path).unwrap(), first_bytes);
// Regression oracle for the observed event order:
// frame read absent -> later real status Completed -> real frame promoted
// BEFORE provisional clear/fallback. This calls the production policy on
// an actual completed native handle; it does not fake the earlier read.
- assert!(!final_job_still_running(&mut h.app,&second));
- assert_eq!(h.app.render_workspace.view.completed,Some(second.id()));
- cache_render_images(&mut h.app,&ctx);
+ assert!(!final_job_still_running(&mut h.app, &second));
+ assert_eq!(h.app.render_workspace.view.completed, Some(second.id()));
+ cache_render_images(&mut h.app, &ctx);
assert!(!displayed_image(&h.app).unwrap().provisional());
assert!(h.app.render_workspace.final_partial.is_none());
let new_path = h.dir.join("manual-final-current.png");
let export_job = h.app.render_workspace.view.completed.unwrap();
- h.app.run("render.export",json!({"job":export_job,"path":new_path})).unwrap();
- assert_eq!(std::fs::read(&new_path).unwrap(),second.frame().unwrap().unwrap().png());
+ h.app.run("render.export", json!({"job":export_job,"path":new_path})).unwrap();
+ assert_eq!(std::fs::read(&new_path).unwrap(), second.frame().unwrap().unwrap().png());
let final_pixels = image::load_from_memory(&std::fs::read(&new_path).unwrap()).unwrap().to_rgba8();
- assert!(final_pixels.pixels().all(|pixel|pixel.0 == [91,33,187,255]));
- assert!(!final_job_still_running(&mut h.app,&first),"a different selected final cannot be reconciled through an old handle");
- assert_eq!(h.app.render_workspace.view.completed,Some(second.id()));
+ assert!(final_pixels.pixels().all(|pixel| pixel.0 == [91, 33, 187, 255]));
+ assert!(!final_job_still_running(&mut h.app, &first), "a different selected final cannot be reconciled through an old handle");
+ assert_eq!(h.app.render_workspace.view.completed, Some(second.id()));
}
fn author_box_optics(h: &mut Harness) {
- h.app.run("solid.box",json!({"length":200,"width":200,"height":4,"center":[0,0,2],"body_name":"ManualOptics"})).unwrap();
- h.app.run("render.scene",json!({})).unwrap();
+ h.app.run("solid.box", json!({"length":200,"width":200,"height":4,"center":[0,0,2],"body_name":"ManualOptics"})).unwrap();
+ h.app.run("render.scene", json!({})).unwrap();
h.app.ui.tab = "RENDER".into();
let view = &mut h.app.render_workspace.view;
view.method = ImageMethod::CpuRaytrace;
view.draft.width = 1;
view.draft.height = 1;
- view.draft.background = [255;3];
+ view.draft.background = [255; 3];
view.draft.stage.clear();
view.draft.surfaces.clear();
view.transparent = false;
@@ -9801,13 +9863,13 @@
view.raytrace.lights = Some(Vec::new());
view.raytrace.shadows = false;
view.cpu.environment = cpu::Environment::Constant;
- view.cpu.color = [1.0;3];
+ view.cpu.color = [1.0; 3];
view.cpu.intensity = 1.0;
- h.app.cam = Camera { distance:10.0,fov:0.0,..Default::default() };
+ h.app.cam = Camera { distance: 10.0, fov: 0.0, ..Default::default() };
h.app.cam.set_view(StandardView::Top);
- h.app.cam.target = solidcraft_engine::geom::Vec3::new(0.0,0.0,2.0);
- let target = materials::read_targets(&mut h.app).unwrap().into_iter().find(|target|target.name == "ManualOptics").unwrap();
- let mut binding = materials::Binding::new(target,&h.app);
+ h.app.cam.target = solidcraft_engine::geom::Vec3::new(0.0, 0.0, 2.0);
+ let target = materials::read_targets(&mut h.app).unwrap().into_iter().find(|target| target.name == "ManualOptics").unwrap();
+ let mut binding = materials::Binding::new(target, &h.app);
binding.glass_enabled = true;
binding.cpu_optics = true;
binding.glass = materials::Glass::recipe("starter.glass.low-iron-clear").unwrap();
@@ -9820,32 +9882,45 @@
fn editor_text(h: &mut Harness, events: Vec<egui::Event>) -> String {
fn collect(shape: &egui::Shape, text: &mut String) {
match shape {
- egui::Shape::Text(shape) => { text.push_str(shape.galley.text()); text.push('\n'); }
- egui::Shape::Vec(shapes) => for shape in shapes { collect(shape,text); },
+ egui::Shape::Text(shape) => {
+ text.push_str(shape.galley.text());
+ text.push('\n');
+ }
+ egui::Shape::Vec(shapes) => {
+ for shape in shapes {
+ collect(shape, text);
+ }
+ }
_ => {}
}
}
let mut output = h.ctx.run_ui(
- egui::RawInput { screen_rect:Some(egui::Rect::from_min_size(egui::Pos2::ZERO,egui::vec2(480.0,3200.0))),events,..Default::default() },
+ egui::RawInput {
+ screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 3200.0))),
+ events,
+ ..Default::default()
+ },
|ui| {
ui.set_width(420.0);
- materials::selection_controls(&mut h.app,ui,Some("ManualOptics"));
+ materials::selection_controls(&mut h.app, ui, Some("ManualOptics"));
},
);
let mut text = String::new();
- for clipped in &output.shapes { collect(&clipped.shape,&mut text); }
+ for clipped in &output.shapes {
+ collect(&clipped.shape, &mut text);
+ }
output.textures_delta.clear();
text
}
#[test]
fn certification_note_draws_cpu_check_guidance_in_both_sampling_modes_and_legacy_gpu_note() {
- let pending: Value = serde_json::from_str(include_str!(concat!(
- env!("CARGO_MANIFEST_DIR"),"/../../assets/locales/pending/cpu-optical-certification.json"
- ))).unwrap();
+ let pending: Value =
+ serde_json::from_str(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/pending/cpu-optical-certification.json")))
+ .unwrap();
let key = "render.cpu_optics_certification";
- for locale in ["ar","de","en","es","he","ja","ko","vi","zh"] {
- assert_eq!(pending[locale].as_object().unwrap().len(),1);
+ for locale in ["ar", "de", "en", "es", "he", "ja", "ko", "vi", "zh"] {
+ assert_eq!(pending[locale].as_object().unwrap().len(), 1);
assert!(!pending[locale][key].as_str().unwrap().trim().is_empty());
}
let mut h = Harness::new();
@@ -9854,86 +9929,102 @@
h.app.render_workspace.view.raytrace.progressive = None;
let cpu_note = h.app.tr(key).to_string();
let gpu_note = h.app.tr("render.material_certification").to_string();
- assert_ne!(cpu_note,key,"a missing catalog fallback is not a translated note");
- assert_ne!(gpu_note,"render.material_certification");
+ assert_ne!(cpu_note, key, "a missing catalog fallback is not a translated note");
+ assert_ne!(gpu_note, "render.material_certification");
let document = h.app.session.doc.clone();
- let before = (h.app.session.revision,h.app.session.undo.len(),h.app.session.is_dirty(),
- h.app.render_workspace.view.job,h.app.render_workspace.view.completed);
- let binding_before = format!("{:?}",h.app.render_workspace.view.materials.bindings[0]);
- let initial = editor_text(&mut h,Vec::new());
+ let before = (
+ h.app.session.revision,
+ h.app.session.undo.len(),
+ h.app.session.is_dirty(),
+ h.app.render_workspace.view.job,
+ h.app.render_workspace.view.completed,
+ );
+ let binding_before = format!("{:?}", h.app.render_workspace.view.materials.bindings[0]);
+ let initial = editor_text(&mut h, Vec::new());
if !initial.contains(&cpu_note) {
- let heading = format!("dialog:section:{}",h.app.tr("render.appearance_editor.glass"));
+ let heading = format!("dialog:section:{}", h.app.tr("render.appearance_editor.glass"));
let at = crate::scenario::rect_of(&heading).unwrap().center();
- for pressed in [true,false] {
- editor_text(&mut h,vec![egui::Event::PointerMoved(at),egui::Event::PointerButton {
- pos:at,button:egui::PointerButton::Primary,pressed,modifiers:egui::Modifiers::NONE,
- }]);
+ for pressed in [true, false] {
+ editor_text(
+ &mut h,
+ vec![
+ egui::Event::PointerMoved(at),
+ egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
+ ],
+ );
}
}
- for progressive in [None,Some(CpuProgressiveOptions { samples_per_pixel:5,seed:13 })] {
+ for progressive in [None, Some(CpuProgressiveOptions { samples_per_pixel: 5, seed: 13 })] {
h.app.render_workspace.view.method = ImageMethod::CpuRaytrace;
h.app.render_workspace.view.raytrace.progressive = progressive;
- let text = editor_text(&mut h,Vec::new());
- assert!(text.contains(&cpu_note),"{text}");
- assert!(!text.contains(&gpu_note),"{text}");
+ let text = editor_text(&mut h, Vec::new());
+ assert!(text.contains(&cpu_note), "{text}");
+ assert!(!text.contains(&gpu_note), "{text}");
}
h.app.render_workspace.view.method = ImageMethod::GpuRaytrace;
- let text = editor_text(&mut h,Vec::new());
- assert!(text.contains(&gpu_note),"{text}");
- assert!(!text.contains(&cpu_note),"{text}");
- assert_eq!(format!("{:?}",h.app.render_workspace.view.materials.bindings[0]),binding_before);
- assert!(Arc::ptr_eq(&document,&h.app.session.doc));
- assert_eq!(before,(h.app.session.revision,h.app.session.undo.len(),h.app.session.is_dirty(),
- h.app.render_workspace.view.job,h.app.render_workspace.view.completed));
+ let text = editor_text(&mut h, Vec::new());
+ assert!(text.contains(&gpu_note), "{text}");
+ assert!(!text.contains(&cpu_note), "{text}");
+ assert_eq!(format!("{:?}", h.app.render_workspace.view.materials.bindings[0]), binding_before);
+ assert!(Arc::ptr_eq(&document, &h.app.session.doc));
+ assert_eq!(
+ before,
+ (
+ h.app.session.revision,
+ h.app.session.undo.len(),
+ h.app.session.is_dirty(),
+ h.app.render_workspace.view.job,
+ h.app.render_workspace.view.completed
+ )
+ );
// Text/layout evidence only: selecting a note never certifies a CPU/GPU render.
}
#[test]
fn native_progressive_optical_recipe_keeps_authored_ior_certificate_and_final_capture() {
- let Some(mut h) = fixture(1,1,5,[255;3],"progressive_optics") else { return };
+ let Some(mut h) = fixture(1, 1, 5, [255; 3], "progressive_optics") else { return };
author_box_optics(&mut h);
- h.app.render_workspace.view.raytrace.progressive = Some(CpuProgressiveOptions { samples_per_pixel:5,seed:13 });
+ h.app.render_workspace.view.raytrace.progressive = Some(CpuProgressiveOptions { samples_per_pixel: 5, seed: 13 });
// The orthographic pixel's entire jitter support lies in +/-4mm,
// strictly inside the independently authored +/-100mm box footprint.
- assert_eq!(h.app.cam.half_height(),4.0);
- for (x,y) in [(0.0,0.0),(1.0,0.0),(0.0,1.0),(1.0,1.0)] {
- let (origin,direction) = h.app.cam.ray(x,y,1.0,1.0);
+ assert_eq!(h.app.cam.half_height(), 4.0);
+ for (x, y) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] {
+ let (origin, direction) = h.app.cam.ray(x, y, 1.0, 1.0);
assert!(direction.x.abs() < 1e-12 && direction.y.abs() < 1e-12);
- assert!((direction.z.abs()-1.0).abs() < 1e-12);
- let point = origin + direction * ((4.0-origin.z)/direction.z);
- assert!(point.x.abs() <= 4.0+1e-9 && point.y.abs() <= 4.0+1e-9);
+ assert!((direction.z.abs() - 1.0).abs() < 1e-12);
+ let point = origin + direction * ((4.0 - origin.z) / direction.z);
+ assert!(point.x.abs() <= 4.0 + 1e-9 && point.y.abs() <= 4.0 + 1e-9);
assert!(point.x.abs() < 100.0 && point.y.abs() < 100.0);
}
let document = h.app.session.doc.clone();
- let before = (h.app.session.revision,h.app.session.undo.len(),h.app.session.is_dirty());
- let binding_before = format!("{:?}",h.app.render_workspace.view.materials.bindings[0]);
+ let before = (h.app.session.revision, h.app.session.undo.len(), h.app.session.is_dirty());
+ let binding_before = format!("{:?}", h.app.render_workspace.view.materials.bindings[0]);
let request = image_params(&h.app).unwrap();
- assert_eq!(request["progressive_feedback"],true);
+ assert_eq!(request["progressive_feedback"], true);
let optics = &request["raytrace"]["dielectrics"][0];
- assert_eq!(optics["body_name"],"ManualOptics");
- assert_eq!(optics["boundary"],"closed_box");
- assert_eq!(optics["ior"],1.5,"the production recipe IOR is not reduced for this case");
- for (index,transmission) in [0.98_f64,0.99,1.0].into_iter().enumerate() {
- let expected = -transmission.ln()/4.0;
- assert!((optics["absorption_per_mm"][index].as_f64().unwrap()-expected).abs() < 1e-15);
- }
- let checked = h.app.session.execute("render.preflight",&request).unwrap();
+ assert_eq!(optics["body_name"], "ManualOptics");
+ assert_eq!(optics["boundary"], "closed_box");
+ assert_eq!(optics["ior"], 1.5, "the production recipe IOR is not reduced for this case");
+ for (index, transmission) in [0.98_f64, 0.99, 1.0].into_iter().enumerate() {
+ let expected = -transmission.ln() / 4.0;
+ assert!((optics["absorption_per_mm"][index].as_f64().unwrap() - expected).abs() < 1e-15);
+ }
+ let checked = h.app.session.execute("render.preflight", &request).unwrap();
let handle = actual_final(&mut h);
let status = handle.status().unwrap();
- assert_eq!(serde_json::to_value(&status.request).unwrap(),checked["request"]);
- assert_eq!(json!(status.request.raytrace),request["raytrace"]);
+ assert_eq!(serde_json::to_value(&status.request).unwrap(), checked["request"]);
+ assert_eq!(json!(status.request.raytrace), request["raytrace"]);
assert!(!status.options.nested_media);
let frame = handle.frame().unwrap().unwrap();
let pixels = image::load_from_memory(frame.png()).unwrap().to_rgba8();
- assert_eq!(pixels.dimensions(),(1,1));
- let pixel = pixels.get_pixel(0,0).0;
- assert_eq!(pixel[3],255);
- assert!(pixel[..3].iter().any(|value|*value > 0));
- assert_eq!(format!("{:?}",h.app.render_workspace.view.materials.bindings[0]),binding_before);
- assert!(Arc::ptr_eq(&document,&h.app.session.doc));
- assert_eq!(before,(h.app.session.revision,h.app.session.undo.len(),h.app.session.is_dirty()));
+ assert_eq!(pixels.dimensions(), (1, 1));
+ let pixel = pixels.get_pixel(0, 0).0;
+ assert_eq!(pixel[3], 255);
+ assert!(pixel[..3].iter().any(|value| *value > 0));
+ assert_eq!(format!("{:?}", h.app.render_workspace.view.materials.bindings[0]), binding_before);
+ assert!(Arc::ptr_eq(&document, &h.app.session.doc));
+ assert_eq!(before, (h.app.session.revision, h.app.session.undo.len(), h.app.session.is_dirty()));
// Actual CPU capture/completion + nonblack optical smoke, not a full
// Fresnel/TIR oracle or a partial-optical-tree while-running receipt.
}
-
-}
+}
@@ -0,0 +1,289 @@
{
"schema": 1,
"status": "EXACT_SOURCE_ONLY_FORMATTING_BRIDGE_NOT_A_TEST_PASS",
"base": "../actual19-reviewed-source/README.md",
"base_composition_sha256": "605292e1edb0ffaa2e1830cc3d277f006e6fc218f8ec6fe0dc2bc27b321ffe69",
"base_adopted_source_manifest_sha256": "5ec1e34bfc91f4e0dc5970191bc34199ad856d2cd998a96f605e01662bf439f8",
"C15_initial_source_manifest_sha256": "f9c2651977c95ee2120ae868c5ff4aeceeaae40e31f8a4cd3a6405d61299f57d",
"C15_post_format_source_manifest_sha256": "2fe4f37371e7376e02ae89a080e51fad82e6f6c564bfe8b8a8075c2951fe1f31",
"source_members": 3571,
"files_changed": 12,
"patch": {
"parts": [
{
"path": "c15-postformat-delta-01.patch",
"bytes": 64030,
"sha256": "1fde9b825ed95b18929f3284c89140d6340026edd5314a797079a187571f19ab"
},
{
"path": "c15-postformat-delta-02.patch",
"bytes": 78431,
"sha256": "d584c829f2325c9801433617366b8cc38dc4f681fecf2dd5fef2f5c105dd24ae"
},
{
"path": "c15-postformat-delta-03.patch",
"bytes": 54381,
"sha256": "e1c151d97dce0ffdece14bbbf02ec551abc177145b739dce267d4bb0018ddf9a"
}
],
"bytes": 196842,
"sha256": "e545a3323c50e0e5f0e55ccbe0260b7fa2d45b3cb1999a78328c1e6fdac21508",
"parts_preserve_source_file_boundaries": true
},
"files": [
{
"path": "crates/engine/src/cmd/render/jobs.rs",
"before": {
"sha256": "ff82cad9aae9f57c81c0da40bd4a7ebbfc390cbbc7fe70ef498eb21a5eb07eea",
"bytes": 63778,
"eol": "LF",
"crlf": 0,
"lf": 1229,
"utf8_bom": false
},
"candidate": {
"sha256": "741caaaeb765853b6d89e0a69eaa8f425654689433a8eee48d3587d276144dd2",
"bytes": 64072,
"eol": "LF",
"crlf": 0,
"lf": 1255,
"utf8_bom": false
},
"normalized_diff_hunks": 11,
"text_change": true
},
{
"path": "crates/engine/src/render_jobs.rs",
"before": {
"sha256": "5655d5f5ae2c80560f9e067ca24424c0561cc5969260ff0343660e4d198909aa",
"bytes": 42740,
"eol": "CRLF",
"crlf": 835,
"lf": 835,
"utf8_bom": false
},
"candidate": {
"sha256": "4a2d5c1886ca029d383874f9aece71b866390fad2b6651193e218b2c302c5f48",
"bytes": 42310,
"eol": "LF",
"crlf": 0,
"lf": 856,
"utf8_bom": false
},
"normalized_diff_hunks": 5,
"text_change": true
},
{
"path": "crates/render/src/job/host.rs",
"before": {
"sha256": "577295a655ab5a8f6204da2b9d2a6a18d3d2bfeb877993a0dfffdc58462a2551",
"bytes": 115822,
"eol": "CRLF",
"crlf": 2406,
"lf": 2406,
"utf8_bom": false
},
"candidate": {
"sha256": "1884acfd018cb38683bd6ec92b171eff5a1b39eb5fd7e79bec0e31a3e76aa879",
"bytes": 115999,
"eol": "LF",
"crlf": 0,
"lf": 2587,
"utf8_bom": false
},
"normalized_diff_hunks": 31,
"text_change": true
},
{
"path": "crates/render/src/job/image_tests.rs",
"before": {
"sha256": "4878271823870137cddb4f7f96c61b7e7e17bbfe46488858fcaedd9448c505dd",
"bytes": 19561,
"eol": "CRLF",
"crlf": 402,
"lf": 402,
"utf8_bom": false
},
"candidate": {
"sha256": "98dfbb281a16aea600a6cff86652b3c92e566eb1188c2942a873df5742183368",
"bytes": 19074,
"eol": "LF",
"crlf": 0,
"lf": 390,
"utf8_bom": false
},
"normalized_diff_hunks": 3,
"text_change": true
},
{
"path": "crates/render/src/job/mod.rs",
"before": {
"sha256": "601391ecc07447e6c65cb3b790c7fe83b839374fa600fc7f79a2668cdf7b3eb0",
"bytes": 62159,
"eol": "LF",
"crlf": 0,
"lf": 1477,
"utf8_bom": false
},
"candidate": {
"sha256": "10b26a76921445cce0029a19e2c9e591663dc5b8876c055b2f07618a4ddeab74",
"bytes": 62100,
"eol": "LF",
"crlf": 0,
"lf": 1474,
"utf8_bom": false
},
"normalized_diff_hunks": 1,
"text_change": true
},
{
"path": "crates/render/src/job/photographic.rs",
"before": {
"sha256": "74756050d449a3333130e54d54b72aed9de2d54ca535d5b50803c30a9ec35c4b",
"bytes": 28171,
"eol": "LF",
"crlf": 0,
"lf": 596,
"utf8_bom": false
},
"candidate": {
"sha256": "03973c7f40398fcd32e24df61678570f827da9e41944817f72dd21ae38e56243",
"bytes": 28130,
"eol": "LF",
"crlf": 0,
"lf": 593,
"utf8_bom": false
},
"normalized_diff_hunks": 3,
"text_change": true
},
{
"path": "crates/render/src/raytrace/image_tests.rs",
"before": {
"sha256": "08bbf206da1436f39c225a14529135b47f7381fc3c278ed2c1dd9564c5c5747c",
"bytes": 114405,
"eol": "LF",
"crlf": 0,
"lf": 2414,
"utf8_bom": false
},
"candidate": {
"sha256": "4a4f46bc26e560fe4087a4fbf4439ea768d05fec2a414af7ad371f5a6897f62d",
"bytes": 115864,
"eol": "LF",
"crlf": 0,
"lf": 2455,
"utf8_bom": false
},
"normalized_diff_hunks": 10,
"text_change": true
},
{
"path": "crates/render/src/raytrace/primary_aa_tests.rs",
"before": {
"sha256": "37ac1993d703bb524d038ec061d621fe0facb44dbdd6d11861d830ae6123d916",
"bytes": 89108,
"eol": "LF",
"crlf": 0,
"lf": 1762,
"utf8_bom": false
},
"candidate": {
"sha256": "66f4d53f8f544c8eeaad8ef7879b50163bcf64528d9aafd20f2c248b1e758e50",
"bytes": 89705,
"eol": "LF",
"crlf": 0,
"lf": 1810,
"utf8_bom": false
},
"normalized_diff_hunks": 5,
"text_change": true
},
{
"path": "crates/render/src/studio.rs",
"before": {
"sha256": "cf06738ea3c14638eb43ce5c05516a6a95d69ac1658464932bd13e54fb4fedf5",
"bytes": 11331,
"eol": "CRLF",
"crlf": 218,
"lf": 218,
"utf8_bom": false
},
"candidate": {
"sha256": "39eec7cc41207f966994b980a2cec11661a89f7e7a019608616f584261764d26",
"bytes": 11054,
"eol": "LF",
"crlf": 0,
"lf": 213,
"utf8_bom": false
},
"normalized_diff_hunks": 4,
"text_change": true
},
{
"path": "crates/ui-egui/src/render_workspace.rs",
"before": {
"sha256": "dd3dc088fdc1080a493eb52f87978fbe8bd36abdfe844f7bbf47b56a986aa7ed",
"bytes": 109538,
"eol": "CRLF",
"crlf": 2395,
"lf": 2395,
"utf8_bom": false
},
"candidate": {
"sha256": "e202f6006332d21942e6bd01a4889601c77f2f1ab84039c0bc5aea2fd0cc18f0",
"bytes": 107739,
"eol": "LF",
"crlf": 0,
"lf": 2444,
"utf8_bom": false
},
"normalized_diff_hunks": 10,
"text_change": true
},
{
"path": "crates/ui-egui/src/render_workspace_materials.rs",
"before": {
"sha256": "e290b9e2404d68524a6a6cb4355b3f11f8bb989e370c869b2a262b1faafa8519",
"bytes": 49558,
"eol": "CRLF",
"crlf": 1045,
"lf": 1045,
"utf8_bom": false
},
"candidate": {
"sha256": "5f9efcda8d42627eea7b05a8ccea2006a265492325824885442c76b6ec07e85a",
"bytes": 48776,
"eol": "LF",
"crlf": 0,
"lf": 1062,
"utf8_bom": false
},
"normalized_diff_hunks": 4,
"text_change": true
},
{
"path": "crates/ui-egui/src/render_workspace_tests.rs",
"before": {
"sha256": "edc06b2999b190b85495db5496d68801f77f370a7c8e2763c25d7610841a085b",
"bytes": 543384,
"eol": "LF",
"crlf": 0,
"lf": 9939,
"utf8_bom": false
},
"candidate": {
"sha256": "5eeb9bb333cad6936dae8bade8c3be0eece2cea7f52d274c6bd59b28c42625d9",
"bytes": 545234,
"eol": "LF",
"crlf": 0,
"lf": 10030,
"utf8_bom": false
},
"normalized_diff_hunks": 22,
"text_change": true
}
],
"patch_policy": "LF-normalized text hunks; verify exact RAW before hashes, apply only textual/EOL reconstruction, restore declared endpoint EOL and verify exact RAW candidate SHA. No universal literal RAW replay claim. All endpoints were read from actual candidate/stage payloads, not reconstructed from hashes.",
"C15_outcome": "Formatter/check completed; later combined test build failed with four E0599 in new UI fixture setup. Zero tests executed;242 selected cases unrun. This formatting bridge transfers no PASS to C16."
}
@@ -0,0 +1,273 @@
diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs
--- a/crates/ui-egui/src/prefs.rs
+++ b/crates/ui-egui/src/prefs.rs
@@ -940,6 +940,8 @@
vertex_markers: bool = false => check("Vertex markers", "Show a small dot at every vertex wherever edges are drawn");
/// Restore the previous platform-specific fixed Render preview geometry budget.
render_previous_geometry_budget: bool = false, PAGE_GRAPHICS => check_id("prefs.render_previous_geometry_budget");
+ /// Open Result once after a successful GUI Start; false preserves the selected view.
+ render_open_result_on_start: bool = true, PAGE_GRAPHICS => check_id("prefs.render_open_result_on_start");
/// How finely the viewport tessellates curved faces and edges: `coarse`, `normal`, `fine` or
/// `ultra` (#1287). Only the display plan the viewport draws; never the design or exports.
display_quality: String = "normal".into(), PAGE_GRAPHICS;
@@ -3331,6 +3333,24 @@
#[cfg(test)]
mod tests {
+ #[test]
+ fn render_open_result_on_start_defaults_persists_validates_and_resets() {
+ assert!(Prefs::default().render_open_result_on_start);
+ assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().render_open_result_on_start);
+ let mut app = SolveApp::new(Default::default(), Default::default());
+ let reply = super::set(&mut app, &json!({"render_open_result_on_start":false})).unwrap();
+ assert_eq!(reply["render_open_result_on_start"], false);
+ for invalid in [json!("false"), json!(0), json!(null)] {
+ assert!(super::set(&mut app, &json!({"render_open_result_on_start":invalid})).is_err());
+ assert!(!app.preferences.render_open_result_on_start);
+ }
+ let mut restored = SolveApp::new(Default::default(), Default::default());
+ restored.load_prefs(&app.prefs());
+ assert!(!restored.preferences.render_open_result_on_start);
+ super::defaults(&mut restored, super::PAGE_GRAPHICS);
+ assert!(restored.preferences.render_open_result_on_start);
+ }
+
#[test]
fn trackpad_zoom_modifier_roundtrips_and_validates_control_values() {
assert_eq!(Prefs::default().trackpad_zoom_modifier, TrackpadZoomModifier::Off);
diff --git a/crates/ui-egui/src/prefs/graphics.rs b/crates/ui-egui/src/prefs/graphics.rs
--- a/crates/ui-egui/src/prefs/graphics.rs
+++ b/crates/ui-egui/src/prefs/graphics.rs
@@ -189,6 +189,7 @@
#[cfg(target_os = "linux")]
ui_kit::settings_section(ui, "window_system", app.tr("ui.window_system"), |ui| window_system_row(app, ui));
setting::render_previous_geometry_budget(app, ui).on_hover_text(app.tr("prefs.render_previous_geometry_budget_help"));
+ setting::render_open_result_on_start(app, ui).on_hover_text(app.tr("prefs.render_open_result_on_start_help"));
ui.horizontal(|ui| {
ui_kit::text(ui, app.tr_label("Renderer"), ui_kit::Text::Inline);
let renderer = match (&app.viewport.gpu, &app.help.gpu) {
diff --git a/crates/ui-egui/src/render_workspace_ribbon.rs b/crates/ui-egui/src/render_workspace_ribbon.rs
--- a/crates/ui-egui/src/render_workspace_ribbon.rs
+++ b/crates/ui-egui/src/render_workspace_ribbon.rs
@@ -150,6 +150,9 @@
.map_err(|e| crate::i18n::format("render.error_job_identity", &[("error", &e.to_string())]))?;
app.render_workspace.view.job = Some(job);
app.render_workspace.view.job_shot = Some(app.render_workspace.view.name.clone());
+ if app.preferences.render_open_result_on_start {
+ app.run("render.result.view", json!({"view":"image"}))?;
+ }
}
"render.result.copy" => {
let job = app.render_workspace.view.completed.ok_or_else(|| app.tr("render.ribbon_no_result").to_string())?;
diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs
--- a/crates/ui-egui/src/render_workspace_tests.rs
+++ b/crates/ui-egui/src/render_workspace_tests.rs
@@ -9643,8 +9643,10 @@
view.draft.width = width;
view.draft.height = height;
view.draft.background = background;
- view.draft.stage.clear();
- view.draft.surfaces.clear();
+ view.draft.stage = Default::default();
+ view.draft.surfaces = Default::default();
+ assert!(view.draft.stage.is_empty());
+ assert!(view.draft.surfaces.is_empty());
view.draft.exposure_ev = 0.0;
view.transparent = false;
view.raytrace = RaytraceOptions {
@@ -9855,8 +9857,10 @@
view.draft.width = 1;
view.draft.height = 1;
view.draft.background = [255; 3];
- view.draft.stage.clear();
- view.draft.surfaces.clear();
+ view.draft.stage = Default::default();
+ view.draft.surfaces = Default::default();
+ assert!(view.draft.stage.is_empty());
+ assert!(view.draft.surfaces.is_empty());
view.transparent = false;
view.draft.exposure_ev = 0.0;
view.raytrace.primary_samples = 1;
@@ -10026,5 +10030,140 @@
assert_eq!(before, (h.app.session.revision, h.app.session.undo.len(), h.app.session.is_dirty()));
// Actual CPU capture/completion + nonblack optical smoke, not a full
// Fresnel/TIR oracle or a partial-optical-tree while-running receipt.
+ }
+}
+
+
+// These exercise GUI navigation against the real cooperative Raster host.
+// They do not qualify CPU raytracing, progressive cadence or GPU rendering.
+fn auto_result_raster_fixture() -> Harness {
+ let mut h = Harness::new();
+ h.app.run("solid.box", json!({"length":3,"width":4,"height":5})).unwrap();
+ enter(&mut h.app, true).unwrap();
+ let host = solidcraft_engine::render::job::CpuHost::cooperative();
+ h.app.session.set_render_job_host(Some(host.clone())).unwrap();
+ h.app.render_workspace.image_host = Some(host);
+ h.app.render_workspace.view.method = ImageMethod::Raster;
+ h.app.render_workspace.view.draft.width = 13;
+ h.app.render_workspace.view.draft.height = 7;
+ h.app.run("render.result.view", json!({"view":"model"})).unwrap();
+ h
+}
+
+#[test]
+fn auto_result_success_opens_once_and_explicit_model_survives_real_completion() {
+ use solidcraft_engine::render::job::Phase;
+ let mut h = auto_result_raster_fixture();
+ assert!(h.app.preferences.render_open_result_on_start);
+ assert!(!h.app.render_workspace.view.result);
+ let document = h.app.session.doc.clone();
+ let undo = h.app.session.undo.len();
+ let camera = h.app.cam;
+ ribbon::perform_job(&mut h.app, "render.start").unwrap();
+ let job = h.app.render_workspace.view.job.unwrap();
+ let handle = h.app.session.render_job_handle(job).unwrap();
+ assert!(h.app.render_workspace.view.result);
+ assert_eq!(handle.status().unwrap().progress.phase, Phase::Queued);
+ assert!(handle.frame().unwrap().is_none());
+ assert!(h.app.render_workspace.view.completed.is_none());
+ assert!(ribbon::action_ready(&h.app, "render.export").is_err());
+ let request = handle.status().unwrap().request;
+ assert_eq!((request.width, request.height, request.integrator), (13, 7, Integrator::Raster));
+ cache_render_images(&mut h.app, &h.ctx);
+ assert!(h.app.render_workspace.view.result);
+ h.app.run("render.result.view", json!({"view":"model"})).unwrap();
+ assert!(!h.app.render_workspace.view.result);
+ let host = h.app.session.render_job_host().unwrap().clone();
+ for _ in 0..128 {
+ host.pump(16_384).unwrap();
+ cache_render_images(&mut h.app, &h.ctx);
+ assert!(!h.app.render_workspace.view.result, "publication/repaint must respect an explicit Model selection");
+ if handle.frame().unwrap().is_some() {
+ break;
+ }
+ }
+ let frame = handle.frame().unwrap().expect("bounded cooperative Raster work must complete");
+ assert_eq!((frame.info.width, frame.info.height), (13, 7));
+ assert_eq!(h.app.render_workspace.view.completed, Some(job));
+ assert!(ribbon::action_ready(&h.app, "render.export").is_ok());
+ for _ in 0..3 {
+ cache_render_images(&mut h.app, &h.ctx);
+ assert!(!h.app.render_workspace.view.result);
+ }
+ assert!(Arc::ptr_eq(&document, &h.app.session.doc));
+ assert_eq!(h.app.session.undo.len(), undo);
+ assert_eq!(h.app.cam, camera);
+}
+
+#[test]
+fn auto_result_disabled_keeps_both_selected_views_through_start_and_completion() {
+ for selected in [false, true] {
+ let mut h = auto_result_raster_fixture();
+ crate::prefs::set(&mut h.app, &json!({"render_open_result_on_start":false})).unwrap();
+ h.app.run("render.result.view", json!({"view":if selected {"image"} else {"model"}})).unwrap();
+ ribbon::perform_job(&mut h.app, "render.start").unwrap();
+ assert_eq!(h.app.render_workspace.view.result, selected);
+ let job = h.app.render_workspace.view.job.unwrap();
+ let handle = h.app.session.render_job_handle(job).unwrap();
+ assert!(handle.frame().unwrap().is_none());
+ let host = h.app.session.render_job_host().unwrap().clone();
+ for _ in 0..128 {
+ host.pump(16_384).unwrap();
+ cache_render_images(&mut h.app, &h.ctx);
+ assert_eq!(h.app.render_workspace.view.result, selected);
+ if handle.frame().unwrap().is_some() {
+ break;
+ }
+ }
+ assert!(handle.frame().unwrap().is_some(), "bounded cooperative Raster work must complete");
+ assert_eq!(h.app.render_workspace.view.completed, Some(job));
+ cache_render_images(&mut h.app, &h.ctx);
+ assert_eq!(h.app.render_workspace.view.result, selected);
+ }
+}
+
+#[test]
+fn auto_result_check_invalid_start_and_real_queue_full_preserve_selected_view() {
+ use solidcraft_engine::render::job::Phase;
+ for selected in [false, true] {
+ let mut h = auto_result_raster_fixture();
+ assert!(h.app.preferences.render_open_result_on_start);
+ h.app.run("render.result.view", json!({"view":if selected {"image"} else {"model"}})).unwrap();
+ let document = h.app.session.doc.clone();
+ let undo = h.app.session.undo.len();
+ ribbon::perform_job(&mut h.app, "render.preflight").unwrap();
+ assert_eq!(h.app.render_workspace.view.result, selected);
+ assert!(h.app.render_workspace.view.job.is_none());
+ h.app.render_workspace.view.draft.width = 0;
+ assert!(ribbon::perform_job(&mut h.app, "render.preflight").is_err());
+ assert_eq!(h.app.render_workspace.view.result, selected);
+ assert!(ribbon::perform_job(&mut h.app, "render.start").is_err());
+ assert_eq!(h.app.render_workspace.view.result, selected);
+ h.app.render_workspace.view.draft.width = 13;
+ let request = image_params(&h.app).unwrap();
+ let mut jobs = Vec::new();
+ // Queue two real jobs without registering a GUI job.
+ // No pump or worker thread runs between admission and the third Start.
+ for _ in 0..2 {
+ let reply = h.app.session.execute("render.start", &request).unwrap();
+ let job = serde_json::from_value(reply["job"].clone()).unwrap();
+ let handle = h.app.session.render_job_handle(job).unwrap();
+ assert_eq!(handle.status().unwrap().progress.phase, Phase::Queued);
+ jobs.push(handle);
+ }
+ ribbon::action_ready(&h.app, "render.start").unwrap();
+ let error = ribbon::perform_job(&mut h.app, "render.start").unwrap_err();
+ assert!(error.contains("this document already has an active and pending render"), "expected real document QueueFull: {error}");
+ assert_eq!(h.app.render_workspace.view.result, selected);
+ assert!(h.app.render_workspace.view.job.is_none());
+ assert!(h.app.render_workspace.view.completed.is_none());
+ cache_render_images(&mut h.app, &h.ctx);
+ assert_eq!(h.app.render_workspace.view.result, selected);
+ for handle in jobs {
+ assert_eq!(handle.status().unwrap().progress.phase, Phase::Queued);
+ assert!(handle.frame().unwrap().is_none());
+ }
+ assert!(Arc::ptr_eq(&document, &h.app.session.doc));
+ assert_eq!(h.app.session.undo.len(), undo);
}
}
diff --git a/assets/locales/pending/render-result-on-start.json b/assets/locales/pending/render-result-on-start.json
--- /dev/null
+++ b/assets/locales/pending/render-result-on-start.json
@@ -1,0 +1,38 @@
+{
+ "ar": {
+ "prefs.render_open_result_on_start": "فتح النتيجة عند بدء التصيير",
+ "prefs.render_open_result_on_start_help": "افتح النتيجة مرة واحدة بعد بدء التصيير بنجاح. عطّل هذا الخيار للاحتفاظ بالعرض المحدد. يمكنك العودة إلى النموذج أثناء التصيير."
+ },
+ "de": {
+ "prefs.render_open_result_on_start": "Ergebnis beim Renderstart öffnen",
+ "prefs.render_open_result_on_start_help": "Nach erfolgreichem Renderstart einmal das Ergebnis öffnen. Deaktivieren, um die gewählte Ansicht beizubehalten. Während des Renderns können Sie zum Modell zurückkehren."
+ },
+ "en": {
+ "prefs.render_open_result_on_start": "Open Result when rendering starts",
+ "prefs.render_open_result_on_start_help": "After starting a render successfully, open Result once. Turn off to keep your selected view. You can return to Model while rendering."
+ },
+ "es": {
+ "prefs.render_open_result_on_start": "Abrir Resultado al iniciar el renderizado",
+ "prefs.render_open_result_on_start_help": "Abre Resultado una vez tras iniciar correctamente el renderizado. Desactiva esta opción para conservar la vista seleccionada. Puedes volver a Modelo durante el renderizado."
+ },
+ "he": {
+ "prefs.render_open_result_on_start": "פתיחת התוצאה עם תחילת הרינדור",
+ "prefs.render_open_result_on_start_help": "פתיחת התוצאה פעם אחת לאחר התחלת רינדור בהצלחה. יש לבטל אפשרות זו כדי לשמור על התצוגה שנבחרה. אפשר לחזור למודל בזמן הרינדור."
+ },
+ "ja": {
+ "prefs.render_open_result_on_start": "レンダリング開始時に結果を開く",
+ "prefs.render_open_result_on_start_help": "レンダリングの開始に成功したら、結果を一度だけ開きます。無効にすると、選択中の表示を維持します。レンダリング中でもモデルに戻れます。"
+ },
+ "ko": {
+ "prefs.render_open_result_on_start": "렌더링 시작 시 결과 열기",
+ "prefs.render_open_result_on_start_help": "렌더링이 성공적으로 시작되면 결과를 한 번 엽니다. 끄면 선택한 보기가 유지됩니다. 렌더링 중에도 모델로 돌아갈 수 있습니다."
+ },
+ "vi": {
+ "prefs.render_open_result_on_start": "Mở Kết quả khi bắt đầu kết xuất",
+ "prefs.render_open_result_on_start_help": "Mở Kết quả một lần sau khi bắt đầu kết xuất thành công. Tắt để giữ chế độ xem đã chọn. Bạn có thể quay lại Mô hình trong khi kết xuất."
+ },
+ "zh": {
+ "prefs.render_open_result_on_start": "开始渲染时打开结果",
+ "prefs.render_open_result_on_start_help": "成功开始渲染后打开结果一次。关闭此选项可保留当前选中的视图。渲染期间也可以返回模型视图。"
+ }
+}
@@ -0,0 +1,46 @@
{
"schema": 1,
"status": "SOURCE_CHECKPOINT_ADOPTED_REVIEWED_NOT_RUNTIME_QUALIFIED",
"source_paths": 5,
"production_draft": 8938,
"production_head": "a077906234972dbbf11e80c54893f85ece97c8b7",
"authorship": "Worker #1, SolidCraft Render team under0xlillith; independent Designer source review and Engineer exact-source adoption retained.",
"original_source_manifest_sha256": "299b24a28d47411eb53ca322c5b1b01d74e0ac7ef690e9056927f06c12092727",
"original_private_binding_sha256": "1cef2da8c20c602e9e91c9b79105376881916594aac99d6e31a24b2e58e051d1",
"original_RAW_endpoint_manifest_sha256": "cfab0c69aef0731b1c4da4d39c5d967d7e8baea6a5f17052160baec5882d849c",
"independent_source_review_sha256": "60695ce7719bd6c8d83d732fd934c2169caf994f46b3fca9de2a37ecf61b0d5e",
"adopted_source_manifest_sha256": "7a30db715b0f342b37e4f5c53f7297cef1d56e22cf65a55278d6e14fd46cc852",
"source_members_checked_at_adoption": 3572,
"source_patch": {
"path": "candidate.patch",
"bytes": 15592,
"sha256": "254b8e72b7b4ff1f12b866501e10fa37274c8614fbb49e38ecbec2172884b201"
},
"sanitized_bindings": {
"path": "bindings.json",
"sha256": "9f024a40545b2f0c7ff5cc002523ffa2386d1ca48d7cdd993c5b02c498304c67"
},
"C15_format_bridge_manifest": {
"path": "c15-postformat-manifest.json",
"sha256": "e0a5bc8634e9f3e42d7f911b8ce740a5e4251563ab1a3ad27e7ed0361667e68f"
},
"test_declarations": {
"new": 4,
"UI": 255,
"preferences": 117
},
"Changes a default": "yes - successful GUI Start opens Result once, old behaviour kept behind preference render_open_result_on_start=false",
"C15_historical": {
"compiler_errors": "four E0599 in two new UI fixture setups",
"test_bodies_executed": 0,
"selected_cases_unrun": 242,
"PASS_transferred_to_C16": false
},
"new_source_execution_qualification": false,
"limits": [
"Static/endpoint/source review is not Rust compilation, test execution, native layout/UX or image-quality acceptance.",
"Cooperative Raster cases test GUI navigation policy; they do not qualify CPU final streaming or physical GPU rendering.",
"Current protected feedback, glass/certified transport, finishing/export and cross-backend parity limitations remain.",
"Additional strict TIR acceptance and the actual Raster directional consumer are not enabled by these five files."
]
}
@@ -0,0 +1,63 @@
# C20 reviewed composition: adopted working source, unrun
This is the exact 16-path source delta adopted into the working checkout as C20.
It contains two new paths and 19 new Rust test declarations. Adoption here means
source bytes only: C20 has not been formatted, compiled or tested. It is not a
production commit, a completed issue, a full-CI result or native/GPU readiness.
Publishing this archive performs no source application or execution.
## Exact provenance
- Post-format C19 before-source manifest SHA256:
`1bbda5d67a58d1732abd8998ca70eda7486de3b82b2dc7c0c501fb3ae023f480`.
- C20 composition manifest SHA256:
`eb19e1fb3002b610f8d103137af13e998b941ee82ba1e3cb9ea92ee342961894`.
- Adopted C20 working-source manifest SHA256 (3,580 members):
`eb6ec43c4fe6862dcf27d9832e8724abf05b3edcdc945f6a8c3e17f35b76d116`.
- Independent final source review SHA256:
`57b2ec67e923db22be13acc7a28b2abd1334d3c77b99a0872c6c94d782dac7a0`.
- Independent RAW replay audit SHA256:
`31a85aac6e71efe0cf0e012bd4daa69691f6311fc945be67ffd260cce2bb546f`.
These are artifact/source hashes, not Git commits or test results. The portable
bindings pin each before/after file. Apply only against those exact before
hashes; the delta is not a standalone copy of the whole source tree. The earlier
Strict TIR and U17 archives were separate proposals on different receivers.
Their presence did not establish adoption or execution; do not apply them again
on top of this combined C20 delta.
## Included source
C20 combines the exact post-format Strict CPU dielectric and U17 rebinds, two
connected-UI documentation edits, two test-module imports and one final unread
local time-store removal. It preserves the reviewed C19 CPU exposure +/-32,
Raster fields, common Model exposure, view/camera and Shot source contracts.
Original test bodies and oracles remain intact apart from that disclosed
non-observable store removal; no expectations were adapted to renderer output.
Legacy dielectric behavior remains default/omitted; Strict is explicit opt-in
with existing certificates, finite work/cancellation and honest error limits.
There is no general/perfect-glass, convergence or GPU-parity promise. PNG 188 in
the IOR 3 rational witness is not energy proof; distinct energies can quantize
to that same byte.
U17 fixes the narrow explicit-light product sRGB branch. Some(lights), rather
than Frame.product, selects that shader branch; actual ordinary Design producers
use None. The global historical decoder, implicit path and PBR source remain
unchanged. Corrected explicit EV0 pixels deliberately change under its bug
contract. Source preservation is not an executed before/after image comparison.
## Acceptance still pending
The 19 source names are listed in tests-NOT-RUN.txt. Eighteen belong to the TIR
proposal. The remaining U17 test proposes 96 real GPU renders within one test,
not 96 process cases; it is excluded from the native development selection.
All 19 remain uncompiled and unrun for C20.
The proposed development counts of 349 cases and 60 required TRUE branches are
not compiled inventory or observed results. A future exact-source build and
metadata/case selection must establish the actual cases. A marker in source,
an unsupported/optional branch or any prior C19 result cannot count as acceptance.
Shader/layout/API checks, strict GPU, optical/native/UI acceptance, wasm and full
repository CI remain separate. Existing author and license notices remain in
force; no private host/control/log receipt or external asset is exported.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
{
"status": "C20_ADOPTED_WORKING_SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN",
"base_before_source_manifest_sha256": "1bbda5d67a58d1732abd8998ca70eda7486de3b82b2dc7c0c501fb3ae023f480",
"composition_manifest_sha256": "eb19e1fb3002b610f8d103137af13e998b941ee82ba1e3cb9ea92ee342961894",
"adopted_working_source_manifest_sha256": "eb6ec43c4fe6862dcf27d9832e8724abf05b3edcdc945f6a8c3e17f35b76d116",
"adopted_source_members": 3580,
"final_source_review_sha256": "57b2ec67e923db22be13acc7a28b2abd1334d3c77b99a0872c6c94d782dac7a0",
"RAW_replay_audit_sha256": "31a85aac6e71efe0cf0e012bd4daa69691f6311fc945be67ffd260cce2bb546f",
"combined_patch_sha256": "9d9ef330887da1ade04e4047df67814696905e1316f1548a0851b7f6859cc507",
"files": [
{
"path": "assets/locales/pending/cpu-dielectric-transport.json",
"before_sha256": null,
"candidate_sha256": "0f85f08b1a4e041c87b86c849ee5e519ef0dd06903ebc8caae35d20c70c1a6c6",
"before_bytes": 0,
"candidate_bytes": 4694,
"new_file": true
},
{
"path": "assets/shaders/viewport.wgsl",
"before_sha256": "ef1073ade9cef7c69157eed6f4a5146852df004511dad5007274123586deb134",
"candidate_sha256": "a4faec5492b81299bff8f88233962288130e82c05576af2d163379f37a4e83cd",
"before_bytes": 32637,
"candidate_bytes": 32685,
"new_file": false
},
{
"path": "crates/doc/src/format/pending/cpu-dielectric-transport.rs",
"before_sha256": null,
"candidate_sha256": "ee897a13782f4a4db2158e2c3d06cd186eb01a5d378ef36cee0dfc80af163221",
"before_bytes": 0,
"candidate_bytes": 495,
"new_file": true
},
{
"path": "crates/doc/src/render_job.rs",
"before_sha256": "8c3e0670d9d5df947cd29999b7d4e1a89c12ea4e5a8679a12dec2b0b182f0d5b",
"candidate_sha256": "cb102321f3ff97b2530257d82170dd0c7a8187e1becfec0e6b3e03d43e6cb164",
"before_bytes": 15299,
"candidate_bytes": 17762,
"new_file": false
},
{
"path": "crates/doc/src/render_settings.rs",
"before_sha256": "3df240dc96ee50d58c62c983674387344f1067a319139c80b6abb55436f3269e",
"candidate_sha256": "6703c7637f73e7e568dd4d48dc300af5cf9c91a1ff066fe7bd41081a7b9cfe9a",
"before_bytes": 47675,
"candidate_bytes": 47724,
"new_file": false
},
{
"path": "crates/gpu/src/view.rs",
"before_sha256": "26d30e46a5fb86e562af5a1009a839250ebf1fffbdee708fdb53d4ea21a5f794",
"candidate_sha256": "73f06629958a04bb34210ece8d425034a60600efac79d7059590cb6656900c70",
"before_bytes": 266139,
"candidate_bytes": 270632,
"new_file": false
},
{
"path": "crates/render/src/job/mod.rs",
"before_sha256": "4bd3269aa3ab7f12500de300e1a004403b3efa2cdcb5bb119551aa23f7de5b9f",
"candidate_sha256": "7642c9eeb9877a695a2a2a332e941cd1ffcdf598f5aa5f77fcbcb17a663c553f",
"before_bytes": 67651,
"candidate_bytes": 70575,
"new_file": false
},
{
"path": "crates/render/src/raytrace/image.rs",
"before_sha256": "3210cbd9115bd26a5f9bfb7a4e121e79afe2070e17cf02dc8da7feed110ae731",
"candidate_sha256": "928115ae0c8cb460438da47a1b73c94f37e4a8dbf2c48c301957a40eadfb4ccf",
"before_bytes": 112115,
"candidate_bytes": 115405,
"new_file": false
},
{
"path": "crates/render/src/raytrace/image_tests.rs",
"before_sha256": "39db249923ebf799ff72548e50f3d7ff67a6ff8e65b70b9cd5617436fdb876f7",
"candidate_sha256": "713bad32b3d3b6cbabfb2354fa5285b41ff36fb004392b39e25cf502a0d142bb",
"before_bytes": 119436,
"candidate_bytes": 141981,
"new_file": false
},
{
"path": "crates/ui-egui/src/render_workspace_cpu.rs",
"before_sha256": "aa110b13e97c6887f64f449c170c3cdcdf7ba11ec0399372bd0c6134152f158b",
"candidate_sha256": "728e43ebcafc7f638b24beec3f02c7af180e2f021067e6717090dce0f2478360",
"before_bytes": 27938,
"candidate_bytes": 41333,
"new_file": false
},
{
"path": "crates/ui-egui/src/render_workspace_setups.rs",
"before_sha256": "925f8494df6157675dda8a0f85f6c40144b9b6fb08c17c2942d83910e6fcc608",
"candidate_sha256": "5013b8b3f4302f4102c7803c5410639b1dd158e0b6ccd26201c7caf6787fa116",
"before_bytes": 15513,
"candidate_bytes": 15655,
"new_file": false
},
{
"path": "crates/ui-egui/src/render_workspace_tests.rs",
"before_sha256": "48d7b10fd23ec6fcd663df1949ff677a968d8388134cebd38643af44a7e5cfed",
"candidate_sha256": "8f2d6589162ad3d14b0aceb5a80fe0543738d7205ce2498ac0debc89b688ffd2",
"before_bytes": 592734,
"candidate_bytes": 592713,
"new_file": false
},
{
"path": "docs/render-interactive.md",
"before_sha256": "14cf4b47d453c6de0f0c835eac63a98b6f8445fdeb7b5c65d3de538b091577a2",
"candidate_sha256": "f98decbcdb5d91568e103c794a0b785b31d127d6dc439eb8911270534b13be82",
"before_bytes": 4349,
"candidate_bytes": 8333,
"new_file": false
},
{
"path": "docs/render-raytrace-image.md",
"before_sha256": "24ae57d817c66b341b802fbdd0b343607cf0e3c042b68bdcc92a3a2eb52bf3a3",
"candidate_sha256": "c2a63a404f4eab31bc390f889260e91f790ee497186b4b2f3a566a2827f07b6c",
"before_bytes": 13509,
"candidate_bytes": 14832,
"new_file": false
},
{
"path": "docs/render-settings.md",
"before_sha256": "79f92ecb52878652cb0bbc0c427be4c0fce3de6616b918d449f5f55a84910751",
"candidate_sha256": "fee45ded12b0b4f62765b06e05ff4d2cdbe9a28b1cd2588f3f18165797c3c1d0",
"before_bytes": 20793,
"candidate_bytes": 21700,
"new_file": false
},
{
"path": "docs/render-shot-scene.md",
"before_sha256": "d5df48d1f256a2c878812e619a7d3bc07b6fd76c090d7fcc2e44ae9955ae83aa",
"candidate_sha256": "3258f084d6d4f3b1b87771693018505e2405848ac99063d06bb840d2016c1188",
"before_bytes": 3126,
"candidate_bytes": 4931,
"new_file": false
}
],
"new_paths": 2,
"new_Rust_test_declarations": 19,
"native_development_cases_proposed_only": 349,
"required_TRUE_proposed_only": 60,
"compiled_inventory_derived": false,
"U17_one_real_GPU_test_excluded_from_development_proposal": true,
"compiled": false,
"tests_run": 0,
"full_CI_or_native_GPU_GUI_readiness": false,
"archive_is_not_an_adoption_or_execution_action": true
}
@@ -0,0 +1,29 @@
SOURCE ONLY - 19 new original Rust declarations. UNCOMPILED / UNRUN for C20.
These names are not compiled IDs, runtime results or a derived 349-case selection.
crates/render/src/job/mod.rs :: dielectric_transport_wire_policy_preserves_legacy_and_rejects_hostile_values
crates/render/src/job/mod.rs :: strict_dielectric_policy_refuses_unqualified_opaque_continuations_without_mutation
crates/render/src/raytrace/image_tests.rs :: legacy_long_tir_counterexample_loses_the_first_real_transmitted_exit
crates/render/src/raytrace/image_tests.rs :: strict_core_long_tir_fixed_and_progressive_reach_real_exit_and_real_absorber
crates/render/src/raytrace/image_tests.rs :: strict_core_long_tir_keeps_beer_on_all_real_reflected_segments
crates/render/src/raytrace/image_tests.rs :: strict_core_tir_more_than511_real_continuations_still_use_bvh_work_and_cancellation
crates/render/src/raytrace/image_tests.rs :: strict_core_positive_non_tir_depth_limit_is_explicit_instead_of_black_completion
crates/render/src/raytrace/image_tests.rs :: legacy_public_normal_ior_three_has_a_positive_rational_depth_tail_hidden_by_png
crates/render/src/raytrace/image_tests.rs :: public_normal_ior_three_real_positive_tail_still_obeys_cancel_and_work_limit
crates/render/src/raytrace/image_tests.rs :: public_strict_ior_three_positive_non_tir_tail_is_a_real_error_before_final_or_sample
crates/render/src/raytrace/image_tests.rs :: public_strict_rational_tail_preserves_real_cancel_and_ordinary_work_accounting
crates/render/src/raytrace/image_tests.rs :: native_public_final_strict_rational_tail_fails_without_a_frame_or_policy_fallback
crates/ui-egui/src/render_workspace_cpu.rs :: cpu_glass_policy_no_binding_ui_snapshot_save_reopen_load_preserves_portable_choice
crates/ui-egui/src/render_workspace_cpu.rs :: cpu_glass_policy_numeric_lowering_and_active_light_filter_preserve_the_exact_choice
crates/ui-egui/src/render_workspace_cpu.rs :: cpu_glass_policy_selection_preserves_unsupported_opaque_reflection_and_advanced_intent
crates/ui-egui/src/render_workspace_cpu.rs :: native_cpu_glass_policy_public_ui_check_start_and_interactive_preserve_real_material_and_visibility
crates/doc/src/render_job.rs :: cpu_dielectric_transport_saved_field_has_legacy_default_and_explicit_runtime_forward
crates/doc/src/render_job.rs :: cpu_dielectric_transport_document_roundtrip_keeps_choice_without_captured_bindings
crates/gpu/src/view.rs :: explicit_product_dark_channels_follow_one_srgb_transfer_in_both_targets
TIR: 18 new declarations; two new native-required TRUE branch sources remain.
U17: one actual GPU test, 96 proposed renders inside it; excluded from the
native development proposal and still separately unrun.
349 native cases / 60 required TRUE branches are proposals, not observed counts.
A source marker, optional no-adapter return or old C19 result is not acceptance.
@@ -0,0 +1,70 @@
# Coherent preview, saved lighting and exposure - source checkpoint
This preserves the complete 61-path C19 source delta for recovery and review.
It is unformatted, uncompiled and unrun. It is not a production commit, a ready
PR, a completed issue, a GPU qualification or an application usability pass.
The production branch/PR is unchanged by this archive.
The patch is based on the exact C18 working source, not directly on the published
production head. `source-bindings.json` pins every before/after file, byte length
and new-file flag. The cumulative diff contains all changed code, including the
old lines. Apply only against those exact before hashes; do not replace someone
else's later file with a recovered snapshot. Unrelated source is unchanged.
## Included work
- Bounded persisted Raster directional lights. Missing/null preserves the
existing studio; an explicit empty list is dark. Saving, editing, hiding and
reopening preserve the authored rig. The existing backdrop command retains
lighting, exposure and its one Undo step.
- One common exposure authority, finite -32 through +32 EV, for Raster and CPU
as well as the existing GPU contract. Raster shades first, then applies EV
before its existing transfer. The schematic legacy scalar path keeps its
clipping behavior. This does not make Raster a physical transport renderer.
- The existing Model Preview responds to the common EV even when CPU or GPU is
selected for the final render. A validated typed value reaches the product
frame, uniform and cache key. Design frames retain zero exposure.
- Captured Interactive and Result images return before the Model exposure path;
new tests require no second grading, no altered export bytes and stable paused
image ownership after later draft edits.
- Paused-image picking uses the captured framing and pixel rectangle. Weak
document/world identity prevents interaction with stale or unknown geometry,
without creating a second evaluator or mesh producer.
- The existing Shot exposure slider supports the common range. Editing an
inactive saved Shot preserves the active draft and camera. The positive
slider fixtures explicitly activate the saved Shot they intend to edit.
- The original case-0079 optical fixture now supplies the exact pole camera it
asserts. The normal Top command intentionally stays just short of the pole.
This is a test setup correction, not a product camera/tolerance change.
The assembly is Core48, Model12, camera/scene compound, common Model exposure,
local Shot-fixture activation, then the CPU Shot range integration. It excludes
the separate strict-TIR prototype/UI, sampling-preset alternatives and unmerged
upstream device/path changes. Existing author and license notices remain intact.
## Validation status
There are 56 additional test declarations in these source files. Their names
are listed in `tests-NOT-RUN.txt`. They are not execution results. Two exercise
real offscreen GPU pixels and require separate device admission; do not count
an unsupported or skipped branch as acceptance.
The planned development validation combines the five affected library test
artifacts in one normal cached build, then runs affected/dependent cases with
required native CPU branches. That plan is not a test pass. The full repository
gate, a bound desktop build and human-style mouse/keyboard acceptance remain
required before production readiness. No previous pass transfers to this source.
C18's actual run stopped at 78 passed, 1 failed and 174 unrun cases. Its failure
was the original exact-pole fixture assertion before rendering. Those raw
results remain recorded in [the issue](https://github.com/tangency-labs/solidcraft/issues/6201).
The earlier HDR representation correction passed in that run; the newer
source changes here have not been executed.
## Remaining limits
This is not CPU/GPU feature parity or a claim of perfect convergence. Physical
glass/TIR integration, GPU-device acceptance, broader Raster approximations,
color-transfer follow-up and all other open issue items remain separate work.
The existing UI layout and gizmos are preserved. No new backend, timeout,
resolution budget, tolerance, ignored test or golden waiver is introduced.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,759 @@
{
"status": "SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN",
"production_head": "a077906234972dbbf11e80c54893f85ece97c8b7",
"base_working_source_manifest_sha256": "2169c3df9530a634003c7a4bcb680fb2e47d6713937252cb5707c4a5051afa5b",
"private_composition_manifest_sha256": "e70ee3bb9a68fdeeee921ca338b6c1a9e08c8198fe5f07a114166a3af87b446d",
"cumulative_patch_sha256": "f4d98c1f43e30f27c273b68165e0c20477e1cef3a2c8f1b28b6bd10451ce8854",
"changed_paths": 61,
"new_files": 6,
"test_declarations_added": 56,
"files": [
{
"path": "assets/locales/pending/product-preview-exposure.json",
"before_sha256": null,
"candidate_sha256": "6d2cbb0aab78602dbccc1b6e6c12731c038035fcae5db77cbc6a885bb720bab5",
"before_bytes": 0,
"candidate_bytes": 1321,
"candidate_CRLF": 0,
"candidate_LF_only": 29,
"new_file": true,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "assets/locales/pending/raster-preview-exposure.json",
"before_sha256": null,
"candidate_sha256": "637e894c84e64979c939446f085c0acecc87a4baf60aede511f9a2f22db9fef3",
"before_bytes": 0,
"candidate_bytes": 4108,
"candidate_CRLF": 0,
"candidate_LF_only": 47,
"new_file": true,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "assets/shaders/viewport.wgsl",
"before_sha256": "aa8778d9b800ed2109009bc469fcacc4968bd845b86df8bee1b993004ccd1f1d",
"candidate_sha256": "ef1073ade9cef7c69157eed6f4a5146852df004511dad5007274123586deb134",
"before_bytes": 31038,
"candidate_bytes": 32637,
"candidate_CRLF": 761,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/doc/src/format/pending/cpu-exposure-range.rs",
"before_sha256": null,
"candidate_sha256": "a2606f05004ed316b073e6316000a0cdc0f9e45eea074c048a3e4bfa18ba8a96",
"before_bytes": 0,
"candidate_bytes": 508,
"candidate_CRLF": 0,
"candidate_LF_only": 6,
"new_file": true,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/doc/src/format/pending/raster-directional-lights.rs",
"before_sha256": null,
"candidate_sha256": "7d9dc924a4816e73508d03a45e994d9eacd05089d882df11797447e42cdd5f30",
"before_bytes": 0,
"candidate_bytes": 325,
"candidate_CRLF": 0,
"candidate_LF_only": 4,
"new_file": true,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/doc/src/format/pending/raster-linear-exposure.rs",
"before_sha256": null,
"candidate_sha256": "fed5ef06d911b569b811810c5e66dbdefc74066e36e2ea36507767c4da109f6d",
"before_bytes": 0,
"candidate_bytes": 303,
"candidate_CRLF": 0,
"candidate_LF_only": 4,
"new_file": true,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/doc/src/render_job.rs",
"before_sha256": "c13c81340dbba9d92b8dd14186a5be1a7e4e5b129f46aa34b88a83d015815a18",
"candidate_sha256": "8c3e0670d9d5df947cd29999b7d4e1a89c12ea4e5a8679a12dec2b0b182f0d5b",
"before_bytes": 14800,
"candidate_bytes": 15299,
"candidate_CRLF": 355,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/doc/src/render_settings.rs",
"before_sha256": "3abf6fef9af5019d6823306d1fd17729dc9288c872956c57d7d5e6cddc2bd784",
"candidate_sha256": "dfd9ae61be6cc4237accd60247326ffa52f35ff3df7dd4a277cce9d9f3a2a839",
"before_bytes": 42073,
"candidate_bytes": 48237,
"candidate_CRLF": 885,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 17,
"candidate_tests": 22
},
{
"path": "crates/engine/src/cmd/render.rs",
"before_sha256": "c8e029139a5a01efa9196dd45c419fec4a6191f752a86e3b023e2bb0e0fc3ba4",
"candidate_sha256": "14f264d1539760050d594defb0cce0e563445f8a51d37893d76890c1bf2c863b",
"before_bytes": 41491,
"candidate_bytes": 42766,
"candidate_CRLF": 441,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 4,
"candidate_tests": 4
},
{
"path": "crates/engine/src/cmd/render/job_tests.rs",
"before_sha256": "545f7e6043aa8fa1d24787021e199f25e2aebadeb4150641b1b77649ef38292a",
"candidate_sha256": "1563baa00e8b8575e7aafa8cacf75de48f40d21d2a3be609c574811c43b2b3ed",
"before_bytes": 68734,
"candidate_bytes": 82614,
"candidate_CRLF": 1394,
"candidate_LF_only": 65,
"new_file": false,
"before_tests": 31,
"candidate_tests": 38
},
{
"path": "crates/engine/src/cmd/render/jobs.rs",
"before_sha256": "741caaaeb765853b6d89e0a69eaa8f425654689433a8eee48d3587d276144dd2",
"candidate_sha256": "6c1067cd828b93118d2dc494858e5c94ac1c60b001af4e5a7fc14bd585aaab19",
"before_bytes": 64072,
"candidate_bytes": 66323,
"candidate_CRLF": 0,
"candidate_LF_only": 1288,
"new_file": false,
"before_tests": 8,
"candidate_tests": 8
},
{
"path": "crates/engine/src/cmd/render/lights.rs",
"before_sha256": "b15b8eb88c9ac91739cfd875d658a37f5f5a3ea92b9a5da4127e002a8a264adc",
"candidate_sha256": "53f422732157e7f167eb6bdc09dfdbccfd0aa4c1a0418733e6475d12e19f9678",
"before_bytes": 7309,
"candidate_bytes": 8280,
"candidate_CRLF": 167,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/engine/src/cmd/render/settings_tests.rs",
"before_sha256": "5b8de442bbe2f1ad2f5e6c6847decbce9d1bf42cd00e99f92520fc6fb180a26c",
"candidate_sha256": "2b20590353e06847c580c94f99099bdb7ce052caadee45f289b4fb51bd7bc6e0",
"before_bytes": 50851,
"candidate_bytes": 55246,
"candidate_CRLF": 1051,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 27,
"candidate_tests": 31
},
{
"path": "crates/engine/src/render_jobs.rs",
"before_sha256": "4a2d5c1886ca029d383874f9aece71b866390fad2b6651193e218b2c302c5f48",
"candidate_sha256": "e9addd54e0279dbc777764ef7ec00c5a0036f4b910dc39b0463ccb747d5dd7f3",
"before_bytes": 42310,
"candidate_bytes": 42356,
"candidate_CRLF": 0,
"candidate_LF_only": 856,
"new_file": false,
"before_tests": 13,
"candidate_tests": 13
},
{
"path": "crates/engine/src/render_lights.rs",
"before_sha256": "7a32f5b31cc28fb4b8f8efd1a8098d69b03933a17d8b875d6e1b69d8dd056e5a",
"candidate_sha256": "bb7c0bff6a76098fe495cc2037a348d49a7e023f81dfc3835c7fe362c6c09bdd",
"before_bytes": 23226,
"candidate_bytes": 26257,
"candidate_CRLF": 540,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/gpu/src/capture.rs",
"before_sha256": "6107ad8debaa4bbbc9035875f9bb36f11a72522d4810af866cb522cc9dcae76a",
"candidate_sha256": "cadf323ab42293047368f5a5be42680d9259b8a7af0815cf6180e17e68f39021",
"before_bytes": 29888,
"candidate_bytes": 29938,
"candidate_CRLF": 602,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 2,
"candidate_tests": 2
},
{
"path": "crates/gpu/src/path/denoise_tests.rs",
"before_sha256": "19d7d68e870e61e78da7f4f868c52fddb24f389c94d96745aa5f9b785dbfbbe7",
"candidate_sha256": "03bd8fff41ef017ebd4058609474ea7657340edd9f5b5759379248f3aa725b35",
"before_bytes": 16109,
"candidate_bytes": 16155,
"candidate_CRLF": 349,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 6,
"candidate_tests": 6
},
{
"path": "crates/gpu/src/path/mod.rs",
"before_sha256": "56eb1fe9e358d08377e37ec28628463497faaae4365a3890d289ba94a66b1777",
"candidate_sha256": "46c8338e2a3dece93875ee5b786ad580075f81e316eb8d6969ab0aa6be2d6795",
"before_bytes": 51168,
"candidate_bytes": 51620,
"candidate_CRLF": 1040,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/gpu/src/path/tests.rs",
"before_sha256": "ea1d58e5fe879b35724879a65ddd4a738edfc0ea4fd57f17148ffc4a95b89f0a",
"candidate_sha256": "371681ca19ef1d8eedc0fdd1f28882b035b72cc710932ee654b72e616b3ef9f4",
"before_bytes": 166721,
"candidate_bytes": 167617,
"candidate_CRLF": 3348,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 77,
"candidate_tests": 79
},
{
"path": "crates/gpu/src/pbr.rs",
"before_sha256": "abf7a8ee4a49a045fe842d5f5145ac9d1bc0224b16327bebdaddb2d56ae2a384",
"candidate_sha256": "d072e328a2a83c404c76b430efe2eb374c94abca779fbd266322be08669781f6",
"before_bytes": 3991,
"candidate_bytes": 4083,
"candidate_CRLF": 93,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/gpu/src/view.rs",
"before_sha256": "4b22dca27386831212f962af1daa3a30c8d684aa948cbda1a15bfcc815d43299",
"candidate_sha256": "88c55a04a4b2405bd53dea8cebfb48782d31e287a9a8c855aa586da3e1fb89b2",
"before_bytes": 263806,
"candidate_bytes": 271521,
"candidate_CRLF": 5384,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 28,
"candidate_tests": 31
},
{
"path": "crates/gpu/src/view/msaa_check.rs",
"before_sha256": "4d7d6523e974b54cff9a98f53701c07cd2f56880dfce0c84c0197fbfd2d791af",
"candidate_sha256": "ec6340087409e82f33c0ed8288bce6d03771d6ad8f49d26daca889c65994510d",
"before_bytes": 9887,
"candidate_bytes": 9941,
"candidate_CRLF": 213,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/render/examples/cpu_resource_smoke.rs",
"before_sha256": "b01e26fba1472ae504f8b177b2e151d6d077636381727f1fc358d8fb5c927225",
"candidate_sha256": "d72ab03b9b9d0bc286d9683fa577e014ab1cbd95e26171fd699d03c2d625e309",
"before_bytes": 8602,
"candidate_bytes": 8648,
"candidate_CRLF": 188,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/render/src/job/host.rs",
"before_sha256": "1884acfd018cb38683bd6ec92b171eff5a1b39eb5fd7e79bec0e31a3e76aa879",
"candidate_sha256": "0b8e7077f16e52525732b6f13f4b146aeb2950151d8e443f97f95d6e9588ab99",
"before_bytes": 115999,
"candidate_bytes": 116815,
"candidate_CRLF": 0,
"candidate_LF_only": 2594,
"new_file": false,
"before_tests": 8,
"candidate_tests": 8
},
{
"path": "crates/render/src/job/host_regrade_tests.rs",
"before_sha256": "7ccf5300f99f7e97883f5493513c00227713ba06c045b8e6255c33646c2efbf1",
"candidate_sha256": "f9b4b1c5eb2c149d7fb1b5f9e42d1f217bdd43717f3a9e58e0fb5d4a178bba3e",
"before_bytes": 7193,
"candidate_bytes": 7239,
"candidate_CRLF": 171,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 3,
"candidate_tests": 3
},
{
"path": "crates/render/src/job/host_tests.rs",
"before_sha256": "d35a649443908a80db0b061c8ab111d45271bbb941b47f48eed2aca55f6f4f14",
"candidate_sha256": "0e6eb0deadd2b192bd79ff32123b89a62bd35667aa0f74f7aeaebc8047b367d5",
"before_bytes": 97653,
"candidate_bytes": 102154,
"candidate_CRLF": 0,
"candidate_LF_only": 2132,
"new_file": false,
"before_tests": 59,
"candidate_tests": 63
},
{
"path": "crates/render/src/job/image.rs",
"before_sha256": "0e6fa062eba9d5c0f4899fc92d7e7e667bbad295b35ff6bc8addbc9db7fd6285",
"candidate_sha256": "4786f3cfcad3374f9a198148e55323b23f9830ca54075c0c8307fc038a71e559",
"before_bytes": 34526,
"candidate_bytes": 36946,
"candidate_CRLF": 857,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/render/src/job/image_tests.rs",
"before_sha256": "98dfbb281a16aea600a6cff86652b3c92e566eb1188c2942a873df5742183368",
"candidate_sha256": "fbc7c19d28027ecd2b9775f17670d74d2068e613b8057030aadc731634d5cc5f",
"before_bytes": 19074,
"candidate_bytes": 30817,
"candidate_CRLF": 0,
"candidate_LF_only": 597,
"new_file": false,
"before_tests": 13,
"candidate_tests": 23
},
{
"path": "crates/render/src/job/interactive.rs",
"before_sha256": "ccc8aaa5fc76038b39e8829f87b0888ccd92816567ea8b3d83778201364a4cce",
"candidate_sha256": "5fb7062276adfdc22d1fa651750119feb90870b31d12fb0fc0b852c4b430468d",
"before_bytes": 11088,
"candidate_bytes": 11135,
"candidate_CRLF": 289,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/render/src/job/led_strip/tests.rs",
"before_sha256": "f34f73369332748ed6482be31698130d4dc4972fbc9d1a703a50ac18d814c0fb",
"candidate_sha256": "a9111a8d1bf32d68db816ed2272fe6e1e8f61ed574efb52565b4ff73349712f4",
"before_bytes": 11914,
"candidate_bytes": 11960,
"candidate_CRLF": 254,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 12,
"candidate_tests": 12
},
{
"path": "crates/render/src/job/mod.rs",
"before_sha256": "10b26a76921445cce0029a19e2c9e591663dc5b8876c055b2f07618a4ddeab74",
"candidate_sha256": "3fb4d226d51633d846c0f454b0da9401d5ea3e24944a494c8afedfea69e98387",
"before_bytes": 62100,
"candidate_bytes": 67361,
"candidate_CRLF": 0,
"candidate_LF_only": 1575,
"new_file": false,
"before_tests": 4,
"candidate_tests": 5
},
{
"path": "crates/render/src/job/png.rs",
"before_sha256": "2d8c7204b6524515b9f13e0219bde0983f67562f8f49b8b7fae6a0d9ccb470fc",
"candidate_sha256": "338146f59bdf9a950687096f2a0de0a54b0d367618cce0faebf005e74fbdae54",
"before_bytes": 7253,
"candidate_bytes": 7299,
"candidate_CRLF": 0,
"candidate_LF_only": 159,
"new_file": false,
"before_tests": 2,
"candidate_tests": 2
},
{
"path": "crates/render/src/job/raster_lights.rs",
"before_sha256": null,
"candidate_sha256": "3ce43f30255b209dd9ad6bb36034f2678d7c0f03d7b6934ae4ba67dac908d63b",
"before_bytes": 0,
"candidate_bytes": 5463,
"candidate_CRLF": 0,
"candidate_LF_only": 124,
"new_file": true,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/render/src/job/regrade.rs",
"before_sha256": "0f5e83d3d0ecbd0f0235ba6befcc2c34781a75c611ae26ab49e1f13a7c5a32d3",
"candidate_sha256": "83b6e6ffc495b4a2269dc8a32b84c0fc8ac1fa3daf2bd01c90a5469e83b4ddb7",
"before_bytes": 9382,
"candidate_bytes": 9474,
"candidate_CRLF": 0,
"candidate_LF_only": 208,
"new_file": false,
"before_tests": 1,
"candidate_tests": 1
},
{
"path": "crates/render/src/job/rt2_tests.rs",
"before_sha256": "48385581616ef4d5dd3f8a4695fbd491064d1ff2eb99d071bd51b61b8a928fd9",
"candidate_sha256": "042e4c516c77e38c809e5bc3ce24216eb149d1a6d7e7dde3356f50a7bc319510",
"before_bytes": 8507,
"candidate_bytes": 8801,
"candidate_CRLF": 178,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 9,
"candidate_tests": 9
},
{
"path": "crates/render/src/raytrace/anisotropic_faces_tests.rs",
"before_sha256": "0e4689451471f53fc969cb0291d3fa2e45184b11b24aa61982efac5759eef314",
"candidate_sha256": "cb6333528379c5ac2f19a1eeffc87dc4c5c659b2066b37219d2ae850ecb9e77b",
"before_bytes": 11612,
"candidate_bytes": 11658,
"candidate_CRLF": 246,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 8,
"candidate_tests": 8
},
{
"path": "crates/render/src/raytrace/anisotropic_reflection_image_tests.rs",
"before_sha256": "8d994a5fd9ef7711fd853b0e6d79bc5debbd0e36356a1e5ebebabf94feb9bc5d",
"candidate_sha256": "420500ce95b4a469f19d4d2bcf2670cf8da30bd74f428a6a5daf9405730e60f8",
"before_bytes": 14036,
"candidate_bytes": 14082,
"candidate_CRLF": 317,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 7,
"candidate_tests": 7
},
{
"path": "crates/render/src/raytrace/deterministic_primary_tests.rs",
"before_sha256": "efdd751d6b2e208d6be4b7ff7f2ed1c9f35cef8266a140eeff7405494e02ef07",
"candidate_sha256": "f20fd601d2c6c7e04c09bb883e1f14ce6753b6f9ff20affc0f26516d57409569",
"before_bytes": 26598,
"candidate_bytes": 26690,
"candidate_CRLF": 612,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 16,
"candidate_tests": 16
},
{
"path": "crates/render/src/raytrace/dielectric_tests.rs",
"before_sha256": "cd6654b63e310fa8c06972060e00c65d056fa691d52c081f2c01fbf41fd2c5cd",
"candidate_sha256": "bbc8fec18d0edb6656478beef07b742d6b5462eb81b3d688b76b60cf3b46fbba",
"before_bytes": 34054,
"candidate_bytes": 34100,
"candidate_CRLF": 0,
"candidate_LF_only": 735,
"new_file": false,
"before_tests": 33,
"candidate_tests": 33
},
{
"path": "crates/render/src/raytrace/environment_image_tests.rs",
"before_sha256": "48e78f834af1df766d602167c7032628bc158629eec655334d0ed781e8836c85",
"candidate_sha256": "f7fc61094b5f1d20b6872bff8f8282e88fd47f22f58d01b1b3594efcb87214cc",
"before_bytes": 14799,
"candidate_bytes": 14845,
"candidate_CRLF": 305,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 8,
"candidate_tests": 8
},
{
"path": "crates/render/src/raytrace/environment_reflection_tests.rs",
"before_sha256": "24e3d3378d6ef31a940e218cc0cbc072a7e9bad021411c24bb18e22bc861c0af",
"candidate_sha256": "dd48ce51d4657f5b2c09acae31934aff4a5bcd30085b02f55e603f7bfa488512",
"before_bytes": 12780,
"candidate_bytes": 12826,
"candidate_CRLF": 295,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 9,
"candidate_tests": 9
},
{
"path": "crates/render/src/raytrace/image_tests.rs",
"before_sha256": "4a4f46bc26e560fe4087a4fbf4439ea768d05fec2a414af7ad371f5a6897f62d",
"candidate_sha256": "3006fba08770f0c3d2d19f6fb8f4fc83667e6ca5133c90e61d1dad33a894bda8",
"before_bytes": 115864,
"candidate_bytes": 119242,
"candidate_CRLF": 0,
"candidate_LF_only": 2516,
"new_file": false,
"before_tests": 60,
"candidate_tests": 61
},
{
"path": "crates/render/src/raytrace/lighting.rs",
"before_sha256": "d7543bd1d083e8513d0fec0a30a62046dc7dc8a798ede8c63b3f801ce597f7a3",
"candidate_sha256": "d4bc7940af35ced7a727106b371f67b53ec68615cbd3013de6ddcf5d42708486",
"before_bytes": 6470,
"candidate_bytes": 6620,
"candidate_CRLF": 166,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/render/src/raytrace/lighting_tests.rs",
"before_sha256": "3fd8a1cd335141a26a45775d5fa91a5c9bc06d73d9f4be413a989877349cfde5",
"candidate_sha256": "4ceadcafb4aebc60459b47c28015d3e8b88d32f5df0ad46ef0619e57990d871c",
"before_bytes": 7223,
"candidate_bytes": 8369,
"candidate_CRLF": 155,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 7,
"candidate_tests": 8
},
{
"path": "crates/render/src/raytrace/primary_aa_tests.rs",
"before_sha256": "66f4d53f8f544c8eeaad8ef7879b50163bcf64528d9aafd20f2c248b1e758e50",
"candidate_sha256": "19dea7670b94cdab50fc830d9a07e807b23cdb9a4c128edefc1ec47eb058b422",
"before_bytes": 89705,
"candidate_bytes": 89797,
"candidate_CRLF": 0,
"candidate_LF_only": 1810,
"new_file": false,
"before_tests": 46,
"candidate_tests": 46
},
{
"path": "crates/render/src/raytrace/punctual_image_tests.rs",
"before_sha256": "47a59a1361a6dfe6d4fb880c411c5932bbdf8809eb479058bd29237f4ce3f442",
"candidate_sha256": "57a7580ef1e6aef6aa2c4b1949ca219608657fc2c2601e5d749fc6e6e85b28c6",
"before_bytes": 11405,
"candidate_bytes": 11451,
"candidate_CRLF": 275,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 8,
"candidate_tests": 8
},
{
"path": "crates/render/src/raytrace/reflection_image_tests.rs",
"before_sha256": "8148967b0ca991b9d685a9c133f7082213cc00c5f442b27801eb8b500eebe575",
"candidate_sha256": "ecad2bc01f967d41d747401c1483c02e451badf26e4496a59ed13ba12a4e29bd",
"before_bytes": 15786,
"candidate_bytes": 15832,
"candidate_CRLF": 324,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 7,
"candidate_tests": 7
},
{
"path": "crates/ui-egui/src/gpu.rs",
"before_sha256": "488442c00fc31133caeddee44526e5668e0be7925d55aba342d286b646abdc4f",
"candidate_sha256": "f7abbac68a9906adf827021731645062fe1938120fec0fedaaee7ca921a61c83",
"before_bytes": 32671,
"candidate_bytes": 33591,
"candidate_CRLF": 711,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 3,
"candidate_tests": 3
},
{
"path": "crates/ui-egui/src/render_workspace.rs",
"before_sha256": "e202f6006332d21942e6bd01a4889601c77f2f1ab84039c0bc5aea2fd0cc18f0",
"candidate_sha256": "6262d926d6e3dae4d6ed92fee6f429b413cf1de9f9185da2701cd351e5b20ab1",
"before_bytes": 107739,
"candidate_bytes": 108186,
"candidate_CRLF": 0,
"candidate_LF_only": 2448,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/ui-egui/src/render_workspace_environment.rs",
"before_sha256": "475ff8be8dbeb1933971145dee6790be6366b2578bc1e23acf5287593144d17d",
"candidate_sha256": "dd994ac6ce9acf9d028ba29b8967b2c660417e66497a3120cecdd2d6e7422613",
"before_bytes": 44631,
"candidate_bytes": 44635,
"candidate_CRLF": 928,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/ui-egui/src/render_workspace_interactive.rs",
"before_sha256": "ca191e803c2c9dceb96e85668b5c77d31f24475e5048f573e2ca5ed75059e813",
"candidate_sha256": "08481133ffb5d1c18a6d660606d12b265d44cd792af961d6d451349d41c3dc52",
"before_bytes": 49827,
"candidate_bytes": 56047,
"candidate_CRLF": 0,
"candidate_LF_only": 1221,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/ui-egui/src/render_workspace_light.rs",
"before_sha256": "8a8cacc281241a57e7965b039eb73a4e0c896fed7b0428231c9789bac04245c6",
"candidate_sha256": "c659c0e255f6ee0d2e9ce067e6cd7a2e3c2ed44440e33d612d6572bd6a6d4b6f",
"before_bytes": 48416,
"candidate_bytes": 58539,
"candidate_CRLF": 1198,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 6
},
{
"path": "crates/ui-egui/src/render_workspace_setups.rs",
"before_sha256": "eec93ffeb8206b558c7b76980435c0b1b21130160fb0fc58de17c51ccc833d7a",
"candidate_sha256": "925f8494df6157675dda8a0f85f6c40144b9b6fb08c17c2942d83910e6fcc608",
"before_bytes": 14738,
"candidate_bytes": 15513,
"candidate_CRLF": 305,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/ui-egui/src/render_workspace_shot.rs",
"before_sha256": "9cf7b95718c1e79bb480150c48b9ae9fcf91af5467f3455e97ebaf131a5d8730",
"candidate_sha256": "7ec8c9412b1917a1febba899994e7b64957fdac744b7357da0d4121b40a7bdf6",
"before_bytes": 24672,
"candidate_bytes": 24542,
"candidate_CRLF": 512,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/ui-egui/src/render_workspace_tests.rs",
"before_sha256": "29bb602f3f5e02aecb2d3f2caad2cf38c703370d32920cbb3fa24123fee40771",
"candidate_sha256": "009227bc49852e7960958ef174e7135dccb4efbc7cdf79fe17c7c69b2a7bfa5e",
"before_bytes": 553223,
"candidate_bytes": 592316,
"candidate_CRLF": 0,
"candidate_LF_only": 10831,
"new_file": false,
"before_tests": 255,
"candidate_tests": 267
},
{
"path": "crates/ui-egui/src/render_workspace_thumbnails.rs",
"before_sha256": "50c67ceb50118dd7e85f34beae80cf35974588f5e022c5c7461857f617087d29",
"candidate_sha256": "52c90f0afeafa812ced6d66beb16ec2a4c6f434529db59eb1bde428cc44b81ae",
"before_bytes": 7454,
"candidate_bytes": 7500,
"candidate_CRLF": 184,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "crates/ui-egui/src/scale_tests.rs",
"before_sha256": "05ed2d593622e5011b58a0aafece917d95c2b8ed0c4c654ea1dd9628fe7e63e9",
"candidate_sha256": "08fe61d32567e666640022a7f8fdacd000304cf34fe1fcf102e449be72735b86",
"before_bytes": 31266,
"candidate_bytes": 31316,
"candidate_CRLF": 578,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 10,
"candidate_tests": 10
},
{
"path": "crates/ui-egui/src/viewport.rs",
"before_sha256": "ef599f0afede370ea1ae45c965126a1b456f7b4d3df991276fafa77b6e55dfbb",
"candidate_sha256": "372ca58ca563c8c608c5d0c6fbaa30a9689ade86691fb2c2b9b0cc818e24e8ea",
"before_bytes": 605924,
"candidate_bytes": 606040,
"candidate_CRLF": 11460,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 134,
"candidate_tests": 134
},
{
"path": "docs/render-raytrace-image.md",
"before_sha256": "f5bdec81117ce9026dfe6565dea0c274afb11a2bf5bb62be73df3d863d907f55",
"candidate_sha256": "24ae57d817c66b341b802fbdd0b343607cf0e3c042b68bdcc92a3a2eb52bf3a3",
"before_bytes": 13509,
"candidate_bytes": 13509,
"candidate_CRLF": 208,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "docs/render-settings.md",
"before_sha256": "714958b1b3643d7919b6ea5cf0439923948f05aa3991736f42ea0d5902843e72",
"candidate_sha256": "79f92ecb52878652cb0bbc0c427be4c0fce3de6616b918d449f5f55a84910751",
"before_bytes": 19168,
"candidate_bytes": 20793,
"candidate_CRLF": 297,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
},
{
"path": "tools/render-library/native_smoke.rs",
"before_sha256": "f9563f490a5b636415a861b14a61e8c10f0719b86eb3b224c4f7e93b00561b48",
"candidate_sha256": "2f51d83f9c5a68834b944d0c56a07365d9895d83ea52764bee43556e5f85a2e3",
"before_bytes": 6578,
"candidate_bytes": 6624,
"candidate_CRLF": 147,
"candidate_LF_only": 0,
"new_file": false,
"before_tests": 0,
"candidate_tests": 0
}
],
"ordered_layers": [
"Core48",
"Model12",
"Model12_UI_append_after_existing_neutrals",
"Compound3_including_case0079",
"Common_Model_EV_all_methods",
"Case_local_active_Shot_fixture_and_inactive_negative",
"Root_UI_common_CPU_Raster_32_integration",
"Root_CPU_Shot_actual_slider_and_pure_request"
],
"compiled": false,
"tests_run": 0,
"full_CI": false,
"GUI_acceptance": false,
"physical_GPU": false
}
@@ -0,0 +1,58 @@
SOURCE ONLY - 56 additional declarations; none executed for this candidate.
crates/doc/src/render_settings.rs :: raster_saved_exposure_uses_the_existing_field_and_roundtrips_its_real_bounds
crates/doc/src/render_settings.rs :: raster_directional_jobs_roundtrip_original_intent_and_omit_legacy_none
crates/doc/src/render_settings.rs :: saved_raster_rigs_refuse_invalid_values_unknown_controls_and_excess_capacity
crates/doc/src/render_settings.rs :: cpu_exposure_expanded_range_roundtrips_offline_with_one_saved_authority
crates/doc/src/render_settings.rs :: cpu_exposure_expansion_keeps_nonfinite_and_outside_saved_values_invalid
crates/engine/src/cmd/render/job_tests.rs :: raster_saved_ev_reopens_captures_check_start_and_changes_real_png_with_one_authority
crates/engine/src/cmd/render/job_tests.rs :: saved_raster_directional_rig_reopens_and_reaches_check_start_status_png_without_cpu_switch
crates/engine/src/cmd/render/job_tests.rs :: raster_json_is_bounded_before_saved_normalization_and_preserves_null_empty_semantics
crates/engine/src/cmd/render/job_tests.rs :: transient_raster_rig_survives_saved_normalization_check_start_status_and_png
crates/engine/src/cmd/render/job_tests.rs :: raster_rig_rejects_foreign_intent_and_unsupported_setup_controls_without_reserving
crates/engine/src/cmd/render/job_tests.rs :: cpu_exposure_full_saved_range_check_start_preserves_fixed_and_progressive_capture
crates/engine/src/cmd/render/job_tests.rs :: cpu_exposure_outside_expanded_range_fails_check_and_start_before_missing_hdr_input
crates/engine/src/cmd/render/settings_tests.rs :: offline_raster_directional_edit_persists_with_one_undo_and_exact_reopen
crates/engine/src/cmd/render/settings_tests.rs :: saved_raster_remove_all_and_visibility_preserve_explicit_darkness_and_authored_values
crates/engine/src/cmd/render/settings_tests.rs :: saved_raster_unsupported_families_and_controls_refuse_atomically
crates/engine/src/cmd/render/settings_tests.rs :: saved_raster_array_is_bounded_before_settings_merge_and_inline_light_edit
crates/gpu/src/path/tests.rs :: direct_gpu_admission_refuses_explicit_raster_rigs_even_when_empty
crates/gpu/src/path/tests.rs :: direct_gpu_admission_refuses_raster_only_exposure_without_opening_a_device
crates/gpu/src/view.rs :: product_exposure_type_rejects_hostile_values_and_canonicalises_zero
crates/gpu/src/view.rs :: product_exposure_has_its_own_uniform_lane_and_only_invalidates_product_pictures
crates/gpu/src/view.rs :: real_product_exposure_repaints_both_attachment_formats_and_restores_zero_exactly
crates/render/src/job/host_tests.rs :: raster_ev_status_and_task_capture_do_not_change_dynamic_fees_or_enter_registered_work
crates/render/src/job/host_tests.rs :: raster_rig_charges_original_and_task_capacity_then_retains_record_through_eviction
crates/render/src/job/host_tests.rs :: raster_rig_cancel_retains_only_original_request_until_last_handle
crates/render/src/job/host_tests.rs :: registered_and_interactive_routes_refuse_even_empty_raster_rigs_without_work
crates/render/src/job/image_tests.rs :: raster_ev_scales_lambert_radiance_before_the_existing_single_tone_map
crates/render/src/job/image_tests.rs :: raster_ev_covers_implicit_pbr_and_the_weighted_scalar_surrogate_with_original_oracles
crates/render/src/job/image_tests.rs :: raster_ev_legacy_linear_surrogate_preserves_straight_alpha_and_background
crates/render/src/job/image_tests.rs :: raster_ev_zero_wire_is_exact_and_nonfinite_bounds_or_wrong_integrator_refuse
crates/render/src/job/image_tests.rs :: explicit_raster_linear_rgb_reaches_png_with_one_display_transfer
crates/render/src/job/image_tests.rs :: explicit_raster_three_four_five_cosine_reaches_png
crates/render/src/job/image_tests.rs :: explicit_empty_raster_rig_is_dark_while_absence_keeps_legacy
crates/render/src/job/image_tests.rs :: explicit_raster_request_rejects_capacity_conflicts_and_foreign_integrators
crates/render/src/job/image_tests.rs :: explicit_raster_scalar_surrogate_preserves_straight_alpha_without_claiming_glass
crates/render/src/job/image_tests.rs :: direct_raster_task_accounts_empty_rig_capacity_without_changing_dark_or_legacy_paths
crates/render/src/job/mod.rs :: raster_light_wire_is_nullable_bounded_strict_and_fallibly_copied
crates/render/src/raytrace/image_tests.rs :: exposure_full_range_fixed_spatial_and_progressive_keep_linear_film_and_one_display_gain
crates/render/src/raytrace/lighting_tests.rs :: exposure_full_saved_range_has_finite_binary_scaled_rational_outputs
crates/ui-egui/src/render_workspace_light.rs :: inline_raster_array_and_invalid_nested_source_refuse_before_visibility_copy
crates/ui-egui/src/render_workspace_light.rs :: named_raster_excess_reserved_capacity_is_rejected_before_setup_clone
crates/ui-egui/src/render_workspace_light.rs :: valid_inline_named_and_legacy_empty_raster_visibility_preserve_authored_intent
crates/ui-egui/src/render_workspace_light.rs :: explicit_runtime_raster_rig_bypass_keeps_caller_conflicts_authoritative
crates/ui-egui/src/render_workspace_light.rs :: visibility_cannot_erase_ambiguous_named_and_inline_selectors
crates/ui-egui/src/render_workspace_light.rs :: raster_backdrop_commit_preserves_rig_states_exposure_and_one_undo
crates/ui-egui/src/render_workspace_tests.rs :: product_preview_exposure_is_bounded_without_rewriting_method_or_advanced_intent
crates/ui-egui/src/render_workspace_tests.rs :: raster_shot_exposure_uses_the_existing_slider_and_one_undoable_saved_command
crates/ui-egui/src/render_workspace_tests.rs :: paused_known_image_after_finished_pan_blocks_current_camera_targets_but_keeps_navigation_and_escape
crates/ui-egui/src/render_workspace_tests.rs :: paused_aligned_known_image_supports_repeated_material_edits_and_uses_its_painted_rectangle
crates/ui-egui/src/render_workspace_tests.rs :: paused_known_image_rejects_authored_aspect_change_without_blanket_pause_or_final_gate
crates/ui-egui/src/render_workspace_tests.rs :: paused_same_camera_geometry_move_rejects_real_new_targets_and_preserves_old_pixels
crates/ui-egui/src/render_workspace_tests.rs :: paused_same_world_visibility_restore_keeps_selection_and_repeated_material_edits_aligned
crates/ui-egui/src/render_workspace_tests.rs :: paused_same_camera_occurrence_capture_and_pending_move_require_new_accepted_world
crates/ui-egui/src/render_workspace_tests.rs :: product_preview_exposure_does_not_regrade_a_real_native_cpu_interactive_png
crates/ui-egui/src/render_workspace_tests.rs :: product_preview_exposure_does_not_regrade_or_validate_a_retained_real_result
crates/ui-egui/src/render_workspace_tests.rs :: editing_an_inactive_selected_shot_keeps_the_active_setup_draft_and_camera
crates/ui-egui/src/render_workspace_tests.rs :: cpu_shot_exposure_reaches_extended_range_via_slider_and_one_saved_command
@@ -0,0 +1,15 @@
# Coherent25-path reviewed A2 source preparation
This is a PRIVATE TEXT COMPOSITION checkpoint saved canonically for review. It has NOT been applied to the working tree, formatted, compiled or run. It is NOT the new protected-work implementation and is NOT feature-complete, ready or a native result.
It combines exact before/candidate bytes for: nested certified fixed-final glass V2 plus Engine command support; two EV/HDR real-worker test additions; seven output-envelope helpers; the independently source-reviewed C13 pinned-action layout correction; A1 film access plus A2 producer V2 preserving the normal full-pass work tick; reviewed host IF1 with two TOTAL preview slots; and receiver V3 aligning Unknown geometry/canvas blocking with actual paint eligibility. The last fix preserves paused/stale Unknown protection while yielding when final priority/error/missing texture hides the image.
Base: first reconstruct the adjacent ../c12-complete-source candidate against its documented production head, then ALL numbered candidate-NN.patch parts in order. Parts end at source-file boundaries and concatenate byte-for-byte to the combined patch SHA in manifest.json. The SHA manifest records every current frozen before image and final composed candidate. This is an ALTERNATIVE to applying the individual incremental proposal patches: never apply overlapping forms twice. The actual source checkpoint remains separate from production PR8938 head.
The25 selected preimages composed exactly, and all3569 shared source files remained unchanged during preparation. That is text/hash evidence, not Rust type/layout/lint/test evidence. Candidate UI declarations224 preserve the215-test prefix then seven descriptor/decoder policies and two visibility regressions. Pure synthetic descriptor/policy tests are explicitly not CPU lifecycle receipts; actual host/producer and earlier EV/HDR/light tests still need the sole builder.
Independent source review found the specific producer tick and receiver hidden-Unknown defects closed in their V2/V3 corrections. The old shared-cap partial policy STILL permits preview work to starve final work. The separate ../protected-feedback-work-policy increment is being implemented privately to address that; this patch intentionally does not pretend it already exists.
Automated UI-through-native-worker acceptance WHILE Refining lacks an exported deterministic real-worker barrier; no fake CPU result or racy sleep/huge-fixture substitute was added. Native mouse/keyboard acceptance on the original detailed scene remains mandatory after a source-bound application build. C13 remains1PASS/1FAIL/51unrun on its earlier source, and the old narrow-card failure never becomes a pass by source review.
Glass remains explicitly opted-in certified fixed-final boxes only; progressive/general glass, normal material-library optics routing, indirect transport and CPU finishing/export enablement remain open. The helper foundation alone does not enable those effects. No skipped GPU/fullCI/quality/readiness claim follows.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
{
"scope": "25-path private exact-preimage source composition; unapplied/uncompiled/unrun; NOT protected IF2",
"base": "C13 frozen local candidate reconstructed from adjacent complete C12 source checkpoint; production head a077906234972dbbf11e80c54893f85ece97c8b7 alone is insufficient",
"files": [
{
"path": "assets/locales/pending/render-interactive.json",
"before_sha256": "4502966fe59189042d2420defa75091036599c0cb14d573027eb76fca1511a63",
"candidate_sha256": "ad13a494ead1bf1cefd90f369a066e924a06377e3d00fecedf1db37978aed94c"
},
{
"path": "crates/engine/src/cmd/render.rs",
"before_sha256": "63478b87010e4232d524c478ee27517a71b3c5d6059f5facb49cba428ef0aa6b",
"candidate_sha256": "3c9d5ff6a12618d51e8842c509a314f8e6f4bb56026d77c7c77eb350f3e8d87a"
},
{
"path": "crates/engine/src/cmd/render/jobs.rs",
"before_sha256": "1cb96c6189fbbf9b904579d6c230aff004004249fcd89a86c3a82d87dc41bd68",
"candidate_sha256": "81dea315e28b07e57b1fc6563f4875611095666eba52616ee530a63b4fc50f52"
},
{
"path": "crates/engine/src/render_interactive.rs",
"before_sha256": "2b404d5ec53cf3a4e953c842cc3238c90604dc2dc8b3e7582006d5f6e779ad36",
"candidate_sha256": "ab0bff950143d98c5a1da28142eb457d63d76bb19810b6ab9e5e5886d19dbece"
},
{
"path": "crates/render/src/film_primary.rs",
"before_sha256": "0c393f63b803d68186f7eff836397a05ab658a2d2039635d963eeba5a7e2d32a",
"candidate_sha256": "e7cca9354ae514004f5fe5a2c9fd47d7def45c37147f8dbb08871d99e9612145"
},
{
"path": "crates/render/src/film_tests.rs",
"before_sha256": "82dfbd9b4b0dacb149742a96eebbaa1d1422dce9589f272ab3d302f314b3c897",
"candidate_sha256": "c5506561bf59c76e9952bb0660f09d12fa5a4777d00332b7f1e6fba13046e752"
},
{
"path": "crates/render/src/job/bloom.rs",
"before_sha256": "3edb29b88c565da58dfad8b91274bb53e4390759f1677fa2f922183ec581ccc2",
"candidate_sha256": "fa14ded73c398d5f4663995918d1af8065222098ce3fe809b7e153cd0ecc3d5e"
},
{
"path": "crates/render/src/job/host.rs",
"before_sha256": "7d2e248f8c7d6586f18d8b9840da9074514ed4672bc8a8845170a805b1bf9315",
"candidate_sha256": "45178c8d87cc69d6835bc571e492afbd5cf8ecf1b0f28b68ed2b4300bf9d3a56"
},
{
"path": "crates/render/src/job/host_tests.rs",
"before_sha256": "3ed03799be1af84527642893847666a81d79976a3b58cc9551e4c0675676ccd4",
"candidate_sha256": "30e2cb1e46a03e53ec0c454481d038f09a0872921f18a96123ee3b36954dd973"
},
{
"path": "crates/render/src/job/linear_exr.rs",
"before_sha256": "acd80b2d768be38ce6caac28d543b34efb538ed84ddda92c19effcf8c3f0f1f4",
"candidate_sha256": "b716c8f5c17423e55dc12427b938b1837b6072e8f37975e4e479b361ebf8133e"
},
{
"path": "crates/render/src/job/linear_exr_decode.rs",
"before_sha256": "7c6509ab81cceb8593c01554992f265d6f5e50933cec29468a3f71c22914f266",
"candidate_sha256": "972abbb9e0cf5d39ff8bc13c7f42cc95d217f054162c62891f2be856e39ddbfa"
},
{
"path": "crates/render/src/job/mod.rs",
"before_sha256": "03b8cb130fd848a64d30048ecdbfea785e9144827336eb8859473293076bc7ee",
"candidate_sha256": "bde685a752a9f4f2fde6b00e9c0d5067795af7645ce45f2bea7e39ee00fdacc1"
},
{
"path": "crates/render/src/job/photographic.rs",
"before_sha256": "b8fcf284664329ec88f2b173c902444c7f1b360113fd3418ec63845f3ff8ae5d",
"candidate_sha256": "1e3a0d630621f423768390d7c932ad10252a030de4286480b2165a8a2369471a"
},
{
"path": "crates/render/src/job/png.rs",
"before_sha256": "02050a2fb1e9a1afb426b0e7f0ad9a0ccea4163ac0af2f482e99c595a5a6d615",
"candidate_sha256": "4f3baeffdd05aaabc38ce654c412abd6a7df158b74c8324206b60fe1be9178af"
},
{
"path": "crates/render/src/job/presentation.rs",
"before_sha256": "dfd4dca076a7b91e1ea9a88b25f26d728aff67b582ed86834a85fb29349f9e25",
"candidate_sha256": "33647bbb3cebd6edc89997fc2d9febd3b723df1a8c0c87f7f4b56c6b4c0f1acd"
},
{
"path": "crates/render/src/job/regrade.rs",
"before_sha256": "bb5357092aca17a34f7f1dc4e57281cab8876ad8d9490be37c38d03fcf3c6f1c",
"candidate_sha256": "7524d60753e33db5ee057a7ff5c19685752be1f6b19e883349bc94e358b286ed"
},
{
"path": "crates/render/src/raytrace/dielectric.rs",
"before_sha256": "4f74a280c4fbdd5d9eddde9ea126b76622e8f0ebc560e5e4a2d23af1ed71ee67",
"candidate_sha256": "aadf54d50b5e284edd42516d5ec167294653779c6e2c0f9f682e504faaa56b4d"
},
{
"path": "crates/render/src/raytrace/dielectric_tests.rs",
"before_sha256": "2b8bf865c53c42de4f2ad88ff7c8ef6ac9d60b152b63b931831436a01b5eac33",
"candidate_sha256": "c6ba6cc63d7cfa9b61d0d5770efa96b029c2f4835d03744ea7e565280a231bc2"
},
{
"path": "crates/render/src/raytrace/image.rs",
"before_sha256": "58cfe3d43b94d31a34aa09967e507261d149b1e6c46911efa6adfd7321842710",
"candidate_sha256": "11dd3b2bd378a3e6c63c958c5cf16935b4c7a35b03df78252d94fd9b27ddfe43"
},
{
"path": "crates/render/src/raytrace/image_tests.rs",
"before_sha256": "38fc12b5c7e157a9c5594c3926207fee3008e4efc9a9f79213de87bb71497618",
"candidate_sha256": "3b4a6340a7b15bac7f37a15e092b66dc6c376ebd6ffd9bf998316ed80e13d71b"
},
{
"path": "crates/render/src/raytrace/primary_aa_tests.rs",
"before_sha256": "0b5002bbe5b53d7059d7d1602af559b119d44549f0a19e0fa880c71ce5b9db18",
"candidate_sha256": "61bcfdc14ef167c9026637fd7bf7f5e8174a0c17e11c2213e7dffe31b6615f71"
},
{
"path": "crates/render/src/raytrace/progressive.rs",
"before_sha256": "2707ce94d4a0307afa4d17965b9959cd017eb0430dfba82474ed514417b9b76d",
"candidate_sha256": "5dc40f1cdca6cc0041d23607d639401fdd1bce8883112a8af79396ab0ccab68f"
},
{
"path": "crates/ui-egui/src/render_workspace.rs",
"before_sha256": "46970f79648100ffd2676609ff5d9093a317b421aa39f28163c8883dd802d151",
"candidate_sha256": "a2ddd1f7c61c973d638162c115e58d20cab1060568727b13bea0d1506186a950"
},
{
"path": "crates/ui-egui/src/render_workspace_interactive.rs",
"before_sha256": "2b385ccba4e94aaff6318f70a3669cd8205d786edad502e01e3c1e03f2e43579",
"candidate_sha256": "cd024408f9bea23c35ee5b1954e12665bcf53edf2c58a7370495d3a876f9c53a"
},
{
"path": "crates/ui-egui/src/render_workspace_tests.rs",
"before_sha256": "9d88967c9177b90fcf44174f89d7faa003a5a7c8eade73448cd09384e005a22d",
"candidate_sha256": "ffc2017c5a2e5fd150b37582c7a6f21a25ce74639f50cf28b3ce52e5234baef2"
}
],
"patch_sha256": "fc16cad8ed96ad1d18519fa39871713780d529da67457e839ae6f99e20ef049a",
"declared_UI_tests": 224,
"test_bodies_executed_for_this_source": 0,
"patch_parts": [
{
"name": "candidate-01.patch",
"bytes": 99743,
"sha256": "cb19e29200e8d7107d7834671ecdb04a32c45ee3c202386ebaaeb7eb28eb5522"
},
{
"name": "candidate-02.patch",
"bytes": 85287,
"sha256": "2322300a3c1f3f745692ecc3237e9cf171ca74e349e8c30bf03ab5ec8a7265d9"
},
{
"name": "candidate-03.patch",
"bytes": 83103,
"sha256": "237bfd355bbebb9ae77784ebfc917d94e43a7edcc227201fa0b381e812543813"
}
]
}
@@ -0,0 +1,36 @@
# Connected Render documentation v2 - source proposal
This preserves two reviewed documentation edits for the connected Render UI.
They remain unadopted and carry no compiler, runtime, GPU or visual acceptance.
A C19 build bound to the adopted frozen source does not include these unadopted
documentation edits; its results cannot qualify their described behavior.
The Interactive text explains captured-image ownership, paused framing/input
limits, common exposure and the existing support/admission boundaries. The Shot
text distinguishes selecting a saved shot from activating it, covers saved
exposure and protects the active draft/camera when an inactive shot is edited.
These are source-contract descriptions, not a general feature-parity claim.
The corrected transport wording retains dielectric intent for capture and
transport certification. Unsupported progressive combinations, including
anisotropy, still refuse explicitly. It does not promise general glass support
or erase the existing final-render and resource-profile limits.
## Exact patch choice
This packet includes only the cumulative C19-RAW patch 31e5651f. Apply it once to
the exact 14cf4b47/d5df48d1 before-file hashes in source-bindings.json; its results
are f98decbc/3258f084. Before/after files use CRLF. The patch has LF headers and
preserves each payload line's original terminator; do not normalize its bytes.
The original Adapter block remains preserved as RAW bytes.
The separate 5b46c565 bridge from superseded v1 LF endpoints is not included.
It is an alternative for a different exact before state, not a follow-on patch.
Never apply the cumulative and that bridge sequentially. The v1 history and its
normalized-replay defect remain preserved; no v1 RAW success is claimed.
These are source hashes, not Git commits. The two-file delta is not a standalone
copy of C19. Existing documentation structure, authorship and license notices
remain in force. No code, new test, asset or generated result is included.
The docs await separate adoption and exact-source native qualification; this
archive does not authorize either.
@@ -0,0 +1,171 @@
diff --git a/docs/render-interactive.md b/docs/render-interactive.md
--- a/docs/render-interactive.md
+++ b/docs/render-interactive.md
@@ -1,10 +1,51 @@
-# CPU Interactive core
+# CPU Interactive preview
-First slice of #8936 under #6201. `job::interactive` prepares bounded CPU
-progressive requests and coordinates their lifetime. **It is not yet connected
-to the workspace UI.** Entering Render does not start a job. No default,
-document format, final export, panel or gizmo is changed by this slice.
+The workspace adapter connects `job::interactive` to the Render canvas and the
+Interactive card. Select **CPU ray tracing** in Output and use **On** to enable
+the provisional native CPU preview. Entering Render or changing stages does not
+enable it or start a final image. The registered `render.interactive` command
+uses explicit on, pause, resume and off actions.
+Interactive requires an available native worker host supporting CPU ray tracing;
+it is not the web/cooperative Raster path or a GPU path-tracing preview. An
+unsupported method, host or request reports a reason rather than changing
+methods or discarding settings. The final renderer's settings, document format
+and export registry remain separate.
+
+## Workspace behavior
+
+After On, committed camera, appearance, visibility, lighting and request edits
+invalidate the current preview automatically. Capture groups edits after 200 ms
+without a newer change; this is a scheduling rule, not a promised display
+latency. The card exposes Updating, Refining preview, Sample target reached,
+Pause/Resume and Off. A completed Interactive image is still labelled
+Provisional preview; reaching its target does not make it a final result.
+
+Pause cancels owned preview work and may retain accepted pixels as a reference.
+Edits mark that reference Out of date. Resume uses the latest input identity.
+An appearance or lighting edit alone does not prohibit repeated aligned
+selection/material editing. Geometry targets require the accepted image's
+camera, authored aspect, visibility, evaluated world and placement metadata to
+match the current view. A changed camera, hidden body or changed geometry must
+not authorize picking or Paint from stale pixels, including after a navigation
+button is released. Unknown source pixels also cannot claim geometry targets.
+A missing alignment certificate fails closed; painting a retained image does
+not certify its geometry.
+
+The image is fitted without stretching; painting and geometry input share its
+actual pixel rectangle. Empty letterbox space is not a geometry target.
+Navigation and release/Escape handling remain available when geometry input is
+blocked. While an enabled preview awaits its current image, the canvas reserves
+that route rather than painting the fast Model preview as current CPU pixels.
+
+Off, leaving Render or changing documents disables/cancels the owned preview
+context. A manual final temporarily suspends preview capture; it does not
+export an Interactive image or cancel the final job. Result has canvas priority.
+A failed capture waits for a relevant edit or explicit Resume instead of
+starting an unbounded retry loop.
+
+## Preview envelope
+
`cpu_request` clones an immutable final request. It preserves the camera,
background, shadows, exposure, lighting, resources and transport. Only preview
dimensions and camera/reflection sampling change: maximum 1024 pixels along the
@@ -16,8 +57,31 @@
The existing profile and host still enforce geometry, traversal and memory limits.
Rectangle/LED direct-light inputs, their emitter-sample count and area-pattern
seed survive preview lowering unchanged. Only camera/reflection sampling uses
-the preview envelope; dielectric and anisotropic overrides still fail explicitly.
+the preview envelope; dielectric intent is retained for capture and transport
+certification, while unsupported progressive combinations, including anisotropy,
+still fail explicitly. This is not a promise of general glass support.
+See [CPU progressive sampling](render-cpu-progressive.md) and
+[CPU resource profiles](render-cpu-resource-profiles.md) for the separate final
+request and admission contracts.
+## Exposure and image ownership
+
+The saved setup's common exposure is finite and bounded to -32..32 EV.
+With Interactive Off, Model uses that exposure in its fast product preview for
+all final-method choices: CPU ray tracing, GPU path tracing and Raster. Editing
+it does not require Start or enable the selected final executor. This Model
+path still requires its viewport device and retains its current environment
+restriction (rotation 0, intensity 1); it is not a promise of Model/final pixel
+parity or of every final effect being represented.
+
+Interactive receives exposure through its captured CPU request. Retained Result
+and Interactive PNGs return through their own image-display routes before the
+live Model exposure is applied. Editing the current draft must not grade those
+captured pixels a second time. An explicit Effects/regrade action is a separate
+operation. Design's viewport does not inherit product exposure. See
+[Scene and Shot editors](render-shot-scene.md) for active versus selected setup
+semantics and the saved Exposure control.
+
## Adapter contract
- Allocate a distinct transient job session for each document's Interactive
@@ -56,7 +120,11 @@
final finishes; an enabled, unpaused controller then groups the latest edits
again. No method here cancels a final job or exports an image.
-The UI adapter, registered activation/pause commands, current-request identity
-capture, retained paused texture and visual acceptance remain a following slice
-coordinated with #8000/#7375. This is not a claim that those integration gates or
-the installed application's shadow defect are fixed.
+## Validation scope
+
+These routes describe the current source contract, including the connected UI;
+they are not a statement that native visual acceptance or GPU qualification has
+passed on this revision. Core/headless regression coverage does not replace a
+supported native build and admitted runtime checks. Pending integration gates,
+Model/final numeric parity and the installed application's reported shadow
+defect are not declared resolved by this document.
diff --git a/docs/render-shot-scene.md b/docs/render-shot-scene.md
--- a/docs/render-shot-scene.md
+++ b/docs/render-shot-scene.md
@@ -3,7 +3,9 @@
The Scene overview replaces the old Scene page. Entering Render with no selection,
clearing the selection, or selecting nothing opens Scene. Selecting a saved shot
opens its Shot editor; the other editors retain their existing adapters during the
-selection-based Inspector migration.
+selection-based Inspector migration. Browser selection alone does not activate
+that setup. Clicking its thumbnail in the Shots strip switches the active setup;
+the active highlight identifies the draft used by Model and the next render.
Scene follows E1: Stages (Look, Light, Frame, Output), Ready to render, Reminders.
Each stage row summarises current state and navigates through the view commands.
@@ -25,12 +27,32 @@
Lens values are saved to the selected shot's GPU job while retaining its other
options. Depth of field needs GPU path tracing and perspective; aperture and focus
-distance remain visible when disabled. Raster exposure and orthographic FOV stay
-faded with reasons. Changing to orthographic while saved depth of field is enabled
+distance remain visible when disabled. Orthographic FOV stays faded with its
+reason. Changing to orthographic while saved depth of field is enabled
is rejected by the existing settings validation; disable depth of field first.
-Frame retains aspect presets and transient composition guides. Size is read-only;
-Edit in Output selects Output, the home of image dimensions.
+Exposure is enabled for Raster, CPU and GPU setups and edits the selected saved
+shot's common finite -32..32 EV value. A slider drag commits on release as one
+undoable settings command. Editing the active shot also updates its live exposure
+draft; editing an inactive selected shot leaves the active setup's draft and
+camera unchanged. Selecting a different final method does not reset exposure.
+With Interactive Off, the fast Model preview uses common exposure for all three
+methods, without starting a render. Interactive applies it through its captured
+CPU request; retained Interactive and Result pixels are not graded again when
+the live draft changes. Model and final renderers need not produce identical
+pixels. Design's viewport remains independent. See
+[CPU Interactive preview](render-interactive.md) for ownership, support and
+paused-input rules.
+Frame retains aspect presets and transient composition guides. A preset changes
+the selected shot's saved dimensions, not just a crop: for example, 16:9 authors
+1920 × 1080 and 4:5 authors 864 × 1080. Current method/profile validation can
+refuse a preset; it does not silently lower dimensions or samples. Custom follows
+the saved dimensions. The aspect selector/guides are transient view state, so
+undoing a saved size edit and selecting Custom are distinct operations. The
+preview fits its painted frame without stretching and uses those same pixel
+bounds for geometry input. Size is read-only in Shot; Edit in Output selects
+Output, the home of image dimensions.
+
These two editors use the shared workspace header without the old setup form,
page switcher or pinned job footer. Explicit native-studio resource reviews remain
in the library card, keeping Scene limited to orientation and readiness. The
@@ -43,5 +65,8 @@
regression coverage checks every editor widget at 350 and 320 points of content
width, including disabled controls, equal Target cells and untruncated labels.
-Validation uses headless egui harnesses and focused render tests. No running-app
-visual review or GPU-device verification is claimed by this change.
+The source includes headless egui and focused render regression cases for these
+contracts. Their presence does not claim a completed native visual review,
+GPU-device verification or Model/final pixel parity on the revision being
+reviewed. The connected Interactive/common-exposure behavior still requires
+qualification against the exact supported native build.
@@ -0,0 +1,41 @@
{
"status": "SOURCE_ONLY_V2_DOCS_UNADOPTED_NOT_RUNTIME_QUALIFIED",
"base_working_source_manifest_sha256": "3e93e75e4d6382a04cb1d005d3331ca250fd84fded53d8d99f215e8d8c944628",
"source_packet_manifest_sha256": "f4e0e57726a45febf20ab194e5fc7c9019b5366ab6b36dd2c141a0f1bfc6068c",
"patch_sha256": "31e5651f6a521e0ab52dbe140a498939bd13e9ba2e88c688075ca068720ca0ea",
"patch_bytes": 10542,
"application_mode": "cumulative_from_exact_C19_RAW_CRLF",
"files": [
{
"path": "docs/render-interactive.md",
"before_sha256": "14cf4b47d453c6de0f0c835eac63a98b6f8445fdeb7b5c65d3de538b091577a2",
"candidate_sha256": "f98decbcdb5d91568e103c794a0b785b31d127d6dc439eb8911270534b13be82",
"before_bytes": 4349,
"candidate_bytes": 8333,
"encoding": "UTF-8 without BOM",
"candidate_EOL": "CRLF",
"candidate_CRLF": 130,
"candidate_LF_only": 0
},
{
"path": "docs/render-shot-scene.md",
"before_sha256": "d5df48d1f256a2c878812e619a7d3bc07b6fd76c090d7fcc2e44ae9955ae83aa",
"candidate_sha256": "3258f084d6d4f3b1b87771693018505e2405848ac99063d06bb840d2016c1188",
"before_bytes": 3126,
"candidate_bytes": 4931,
"encoding": "UTF-8 without BOM",
"candidate_EOL": "CRLF",
"candidate_CRLF": 72,
"candidate_LF_only": 0
}
],
"alternative_v1_bridge_not_included_sha256": "5b46c565fcafb2413bfbafe1f309d740a075b519cb8c0a1462448b1d9d2be0a9",
"never_apply_both_patches_sequentially": true,
"do_not_normalize_patch_or_endpoints": true,
"source_code_changed": false,
"new_tests": 0,
"adopted": false,
"runtime_qualified": false,
"C19_build_results_transfer": false,
"glass_scope": "Dielectric intent retained for capture/transport certification; unsupported progressive combinations including anisotropy refuse. No general glass guarantee."
}
@@ -0,0 +1,113 @@
# CPU direct-visibility foundation — source proposal
**Unformatted, uncompiled, unrun, unregistered; no image consumer is installed.**
This two-file increment preserves an original bounded CPU light-connection component
for [Render issue #6201](https://github.com/tangency-labs/solidcraft/issues/6201).
It is not an image-shadow repair, a new product default or a CPU/GPU parity result.
## Source and integration boundary
The exact receiver is frozen C20 source-manifest
`eb6ec43c4fe6862dcf27d9832e8724abf05b3edcdc945f6a8c3e17f35b76d116`.
[Portable bindings](source-bindings.json) pin both before/candidate files and the
actual surrounding API sources. The [C20 archive](../c20-reviewed-composition/README.md)
provides its source lineage; the published draft PR HEAD alone is not this receiver.
Only `crates/render/src/raytrace/dielectric.rs` and its **existing**
`dielectric_tests.rs` change. Production is one insertion before the unchanged
test-module router. Tests only append; every prior byte, including all 33 old
declarations and expectations, remains intact. [delta.patch](delta.patch) replays
exactly against the two [before files](before/crates/render/src/raytrace/).
The [candidate files](candidate/crates/render/src/raytrace/) and two addition
fragments preserve identical authored bytes, not newly formatted alternatives.
No image/job/studio/UI/command/schema/serialization/appearance/default/registry or
renderer-selection consumer changes. This packet is separate from C20 and from
the existing Strict-TIR camera-transport proposal. It must not be overlaid on an
arbitrary later formatter output or treated as a complete glass implementation.
## Actual API and optical scope
`DirectVisibility::new(&ProductScene, &RaytraceOptions, Ray, crossing_limit,
&mut WorkBudget, &CancelToken)` creates a borrowed cursor.
`step(&mut self, work, cancel) -> Result<Option<Vec3>>` returns `None` for
pending actual work and `Some(rgb)` only for a completed connection.
Success or any step error makes the cursor terminal.
This is **one straight exterior-to-exterior direct-light approximation**.
Certified thin sheets reuse the existing incoherent polarized-sheet transmission
closure. Certified disjoint axis-aligned boxes use reciprocal external-angle
Fresnel at both unbent interfaces, multiplied by Beer absorption over the original
entry/exit segment. The solid connection includes no reflected returns, bending,
focusing or caustics. Both endpoints must be outside admitted boxes; no partial
in-medium endpoint or extra eta factor is inferred. Existing camera/refraction/TIR
code and its old oracles remain unchanged.
At normal incidence with n=1.5, R=((n-1)/(n+1))^2=1/25.
The straight solid's factor is (1-R)^2=576/625.
The unchanged zero-absorption incoherent sheet instead gives (1-R)/(1+R)=12/13.
These are distinct contracts. With sigma=(ln2,ln4,0), a one-mm index-matched slab
gives (1/2,1/4,1), two slabs multiply to (1/4,1/16,1), and eight to
(1/256,1/65536,1). For sheet plane z=x/2 at unit virtual thickness and index match,
the optical distance is sqrt(5)/2 mm. These are original analytic expectations,
not numerical execution results.
## Geometry, cost and error bounds
- At most eight selected bodies; exact captured body ordinal **and** name are
certified. Thin sheets have one/two consistently wound planar triangles,
boxes twelve complementary outward corner triangles, and certification admits
at most 36 positions. Unsupported meshes never receive a fabricated certificate.
- Crossing limit is explicit 1..=16. Selected nested/touching/overlapping boxes,
selected sheets conservatively touching/overlapping a box, box edge/corner
ambiguity, and origin/start/end on or inside a box refuse.
- Actual scan interval is [min,max): geometry at a finite light endpoint is
excluded. Original origin and normalized direction stay literal; only min
advances to next_up(hit.distance). Co-depth **different** bodies refuse.
A crossed certified sheet's complete body is masked once, preventing its
triangulation diagonal from being counted twice.
- Unselected geometry is explicitly opaque. Only real opaque blocking or
mathematically zero transmission completes black. Pending traversal,
cancellation, ambiguous/malformed input, a missed box exit and exhaustion never
become a white/black fallback result.
- The existing BVH API lacks the all-ties/exclusion seam needed here. This
foundation scans actual borrowed source incrementally, one triangle intersection
or body transition per step, then a separately debited bounded optical stage.
Cost is O((crossings+1)*(triangles+bodies)), **not** accelerated traversal.
Existing caps are 8,000,000 triangles per pass and 262,144 bodies.
- Constructor validation/certification/pair checks and every step spend the
**same caller-owned cumulative WorkBudget**; the caller must not replace/reset it.
No new work ledger or credit is created. Cancellation checks precede new debits.
Work/crossing exhaustion returns existing `TraceError::WorkLimit`;
cancellation returns `Cancelled`; input/identity ambiguity returns errors.
- Fixed arrays/scalars and borrowed scene/options create no new containers during
valid setup/steps. The existing invalid-options validator may allocate its
bounded error String. No compiled size, heap/stack/RSS or performance measurement
was made. Future admission must account actual metadata, retained capacities
and copies instead of assuming this prototype fits.
The unchanged validator also bounds finite IOR to 1..=3, absorption per mm to
0..=100 and sheet thickness to 0..=10,000 mm; closed-box thickness must be zero.
The existing global Ray/WorkBudget limits still apply. This does not increase
output resolution, sample limits or host resources.
## Evidence and remaining work
[Original acceptance inventory](acceptance-cases-NOT-RUN.md): 33 preserved plus
12 new declarations, **all unrun on this candidate**, with fixed original channel
tolerance 1e-13. Static independent review found no actionable defect in the
delivered component. A separate text/hash replay reconstructed both candidates
exactly. Neither establishes Rust typechecking, Clippy, compilation, a numerical
test result, current native/GPU acceptance, full CI or readiness.
Before product enablement: rebind the actual receiver/APIs, integrate a real
`PreparedScene.source` borrowing consumer, admit its true memory/cumulative work,
preserve cancellation and structured errors, and schedule progressive image work.
Then run original component and affected/dependent validation plus real images
and native interaction. General mesh glass, nested direct volumes, refracted
light paths, rough transmission, caustics and demonstrated GPU parity remain
outside this component.
Original SolidCraft contributor source and analytic tests; **MIT OR Apache-2.0**.
No new dependency, third-party asset or commercial renderer reference is added.
Authorship and original assertions are preserved.
@@ -0,0 +1,56 @@
# Original acceptance declarations — NOT RUN
All 45 declarations are unrun on this changed candidate. Names below are source inventories, not test results. The 33 old test bodies are preserved byte-for-byte as the complete prefix of candidate dielectric_tests.rs; the 12 new declarations append to that existing file. No existing expectation/tolerance was modified.
## Twelve added cases
1. `direct_box_beer_uses_original_endpoints_and_multiplies_disjoint_rgb` — One/two disjoint index-matched 1 mm slabs: sigma=(ln2,ln4,0), expected RGB (1/2,1/4,1) and (1/4,1/16,1).
2. `direct_sheet_shared_diagonal_is_one_crossing_and_uses_virtual_beer_distance` — One two-triangle thin sheet/shared diagonal: one crossing and virtual-thickness Beer (1/2,1/4,1).
3. `direct_exterior_box_fresnel_is_not_a_refracted_or_infinite_bounce_slab` — Independent n=1.5 normal-incidence oracles: R=1/25; solid unbent two-interface transmission 576/625, incoherent sheet 12/13; no reflected-return claim for the solid.
4. `direct_connection_keeps_unselected_opaque_blockers_and_half_open_light_endpoint` — Unselected opaque blockers remain black; finite light-endpoint geometry and geometry beyond it are excluded by [min,max).
5. `direct_connection_refuses_coincident_different_bodies_instead_of_skipping_opaque` — Equal-depth selected and opaque different-body identities refuse; no success fallback.
6. `direct_connection_bounds_validate_identity_endpoints_overlap_and_crossing_budget` — Invalid 0/17 crossing limits, box face/interior endpoints/origin, name mismatch and overlapping selected boxes refuse; insufficient crossing budget gives WorkLimit and terminal cursor.
7. `direct_connection_spends_caller_work_and_cancellation_is_terminal_without_result` — Each step spends the same caller ledger; exhausted budget returns WorkLimit; cancellation precedes a new debit; errors make the cursor terminal.
8. `direct_connection_rejects_hostile_optics_before_any_dark_result` — NaN/infinite/negative/out-of-range absorption and NaN IOR are rejected before a result.
9. `direct_connection_accepts_all_eight_disjoint_boxes_and_preserves_reverse_rgb` — Eight disjoint boxes/sixteen boundaries in both directions: (1/256,1/65536,1); ninth selected override refuses.
10. `direct_connection_box_edge_is_refused_without_a_successful_fallback` — Certified box edge/corner ambiguity refuses without a successful fallback.
11. `direct_connection_setup_certification_cannot_escape_caller_work_limit` — Constructor certification spends its caller budget; one-unit budget cannot bypass setup work.
12. `direct_oblique_sheet_is_one_certified_plane_with_original_beer_angle` — Oblique sheet z=x/2: original index-matched virtual optical distance sqrt(5)/2 mm and Beer (2^(-sqrt(5)/2),4^(-sqrt(5)/2),1).
The channel comparison tolerance is the original authored fixed 1e-13, not fitted to execution. The cases derive their values from elementary Fresnel/Beer geometry; they do not use commercial reference images or external renderer output.
## Thirty-three preserved declarations
- `normal_interface_has_four_percent_reflection`
- `thirty_degree_snell_and_parallel_exit_are_reversible`
- `internal_grazing_ray_is_totally_reflected`
- `beer_lambert_uses_actual_millimetre_path`
- `hostile_optics_are_refused`
- `closed_box_certification_requires_complete_outward_boundary`
- `captured_occurrence_identity_and_unsupported_boundary_are_refused`
- `actual_png_closed_volume_absorption_uses_two_and_eight_mm`
- `actual_png_clear_volume_includes_multiple_fresnel_branches`
- `overlapping_volumes_are_refused_before_pixel_work`
- `cancelled_dielectric_job_cannot_publish_png`
- `runtime_dielectric_inputs_are_bounded_and_default_off`
- `oblique_parallel_slab_offset_scales_with_actual_thickness`
- `thin_sheet_is_certified_separately_and_duplicate_faces_refuse`
- `native_dielectric_job_uses_existing_host_and_retained_png_lease`
- `secondary_environment_is_explicit_and_primary_background_remains_display_srgb`
- `typed_runtime_roundtrip_preserves_default_empty_and_explicit_lists`
- `actual_oblique_png_selects_the_snell_shifted_colour_target`
- `thin_sheet_brewster_sums_polarizations_before_averaging`
- `thin_sheet_normal_incidence_keeps_the_lossless_slab_limit`
- `thin_sheet_matched_indices_keep_beer_lambert_transmission`
- `thin_sheet_zero_thickness_index_matched_grazing_is_transparent`
- `thin_sheet_positive_thickness_index_matched_grazing_refuses_without_tir`
- `thin_sheet_zero_thickness_grazing_still_validates_absorption`
- `thin_sheet_absorbing_brewster_keeps_separate_internal_returns`
- `actual_png_brewster_thin_sheet_matches_reflected_energy`
- `strict_box_parent_selection_is_order_independent_and_opt_in`
- `nesting_opt_in_still_refuses_touching_equal_and_partially_overlapping_boxes`
- `actual_nested_png_uses_parent_only_outside_the_inner_interval`
- `progressive_optics_keep_the_nested_certificate_opt_in_and_interactive_intent`
- `native_host_forwards_only_the_existing_nested_media_exception`
- `exact_face_excludes_own_zero_depth_and_retains_one_ulp_neighbour_on_every_axis_and_side`
- `exact_face_recovers_certified_plane_without_clamping_or_blessing_ties`
@@ -0,0 +1,391 @@
//! Original bounded smooth dielectric optics; no renderer source adapted.
//! Air is 1.0. Distances and absorption coefficients use millimetres.
use super::{Bounds, Result, TraceError, Triangle, unit};
use crate::{ProductScene, job::DielectricOptions};
use solidcraft_geom::Vec3;
pub const MAX_OVERRIDES: usize = 8;
pub const MAX_BOUNDARIES: u8 = 8;
pub const MAX_PATHS: u16 = 511;
#[derive(Clone, Copy, Debug)]
pub struct Interface {
pub reflected: Vec3,
pub transmitted: Option<Vec3>,
pub reflectance: f64,
}
/// Exact unpolarized dielectric Fresnel and Snell, with the normal towards
/// the incident medium. Equal indices have no interface, including grazing.
pub fn interface(direction: Vec3, normal: Vec3, incident: f64, transmitted: f64) -> Result<Interface> {
polarized_interface(direction, normal, incident, transmitted).map(|(optics, _)| optics)
}
fn polarized_interface(direction: Vec3, normal: Vec3, incident: f64, transmitted: f64) -> Result<(Interface, [f64; 2])> {
if ![incident, transmitted].into_iter().all(|n| n.is_finite() && (1.0..=3.0).contains(&n)) {
return Err(TraceError::InvalidInput("dielectric IOR outside 1..3"));
}
let d = unit(direction).ok_or(TraceError::InvalidInput("dielectric direction"))?;
let n = unit(normal).ok_or(TraceError::InvalidInput("dielectric normal"))?;
let cosine = -d.dot(n);
if cosine < 0.0 {
return Err(TraceError::InvalidInput("dielectric incident hemisphere"));
}
let cosine = cosine.clamp(0.0, 1.0);
let reflected = unit(d + n * (2.0 * cosine)).ok_or(TraceError::InvalidInput("reflected direction"))?;
if incident == transmitted {
return Ok((Interface { reflected, transmitted: Some(d), reflectance: 0.0 }, [0.0; 2]));
}
let ratio = incident / transmitted;
let sine2 = ratio * ratio * (1.0 - cosine * cosine).max(0.0);
if sine2 >= 1.0 {
return Ok((Interface { reflected, transmitted: None, reflectance: 1.0 }, [1.0; 2]));
}
let cos_t = (1.0 - sine2).sqrt();
let rs = (incident * cosine - transmitted * cos_t) / (incident * cosine + transmitted * cos_t);
let rp = (transmitted * cosine - incident * cos_t) / (transmitted * cosine + incident * cos_t);
let refracted = unit(d * ratio + n * (ratio * cosine - cos_t)).ok_or(TraceError::InvalidInput("refracted direction"))?;
let polarized = [(rs * rs).clamp(0.0, 1.0), (rp * rp).clamp(0.0, 1.0)];
Ok((Interface { reflected, transmitted: Some(refracted), reflectance: (polarized[0] + polarized[1]) * 0.5 }, polarized))
}
/// Incoherent parallel-sheet returns for unpolarized incident light. Sum each
/// polarization's internal returns before averaging; virtual thickness affects
/// absorption only. This closure does not transport Stokes state between hits.
pub(super) fn thin_sheet(direction: Vec3, normal: Vec3, material: &DielectricOptions) -> Result<(Interface, Vec3, Vec3)> {
let direction = unit(direction).ok_or(TraceError::InvalidInput("dielectric direction"))?;
let normal = unit(normal).ok_or(TraceError::InvalidInput("dielectric normal"))?;
let (optics, polarized) = polarized_interface(direction, normal, 1.0, material.ior)?;
let inside = optics.transmitted.ok_or(TraceError::InvalidInput("air to sheet transmission"))?;
let cos_t = -inside.dot(normal);
// Zero virtual thickness has zero optical distance, including the index-matched
// grazing limit. A positive thickness with no crossing direction is undefined.
let distance = if material.thickness_mm == 0.0 {
0.0
} else if cos_t > 0.0 {
material.thickness_mm / cos_t
} else {
return Err(TraceError::InvalidInput("grazing sheet thickness is unsupported"));
};
let a = attenuation(material.absorption_per_mm, distance)?;
let weights = |a: f64| {
let series = |f: f64| {
let denominator = 1.0 - f * f * a * a;
if denominator == 0.0 { (1.0, 0.0) } else { (f + (1.0 - f).powi(2) * f * a * a / denominator, (1.0 - f).powi(2) * a / denominator) }
};
let s = series(polarized[0]);
let p = series(polarized[1]);
((s.0 + p.0) * 0.5, (s.1 + p.1) * 0.5)
};
let (rr, tr) = weights(a.x);
let (rg, tg) = weights(a.y);
let (rb, tb) = weights(a.z);
Ok((Interface { transmitted: Some(direction), ..optics }, Vec3::new(rr, rg, rb), Vec3::new(tr, tg, tb)))
}
pub fn attenuation(sigma: [f64; 3], distance_mm: f64) -> Result<Vec3> {
if !distance_mm.is_finite()
|| !(0.0..=super::MAX_DISTANCE_MM).contains(&distance_mm)
|| !sigma.into_iter().all(|v| v.is_finite() && (0.0..=100.0).contains(&v))
{
return Err(TraceError::InvalidInput("dielectric absorption or distance"));
}
let [r, g, b] = sigma.map(|s| (-s * distance_mm).exp());
Ok(Vec3::new(r, g, b))
}
pub fn multiply(a: Vec3, b: Vec3) -> Vec3 {
Vec3::new(a.x * b.x, a.y * b.y, a.z * b.z)
}
#[derive(Clone, Copy, Debug)]
pub enum Certificate {
ThinSheet,
ClosedBox(Bounds),
}
/// A strict geometric subset, never a trusted mesh.closed flag. Only twelve
/// outward corner triangles, two complementary halves per box face, qualify.
pub fn certify(scene: &ProductScene, input: &DielectricOptions) -> Result<Certificate> {
let body = scene.meshes.get(input.body_index).ok_or(TraceError::InvalidInput("dielectric body ordinal"))?;
if body.name != input.body_name {
return Err(TraceError::InvalidInput("dielectric captured body identity mismatch"));
}
let mesh = &body.mesh;
let closed = matches!(input.boundary, crate::job::DielectricBoundary::ClosedBox);
if (!closed && !(1..=2).contains(&mesh.triangles.len())) || (closed && mesh.triangles.len() != 12) || mesh.positions.len() > 36 {
return Err(TraceError::InvalidInput("unsupported dielectric boundary tessellation"));
}
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = -lo;
let mut tris = [None; 12];
for (slot, indices) in tris.iter_mut().zip(&mesh.triangles) {
let mut vertices = [Vec3::ZERO; 3];
for (v, index) in vertices.iter_mut().zip(indices) {
*v = *mesh.positions.get(*index as usize).ok_or(TraceError::InvalidInput("dielectric vertex index"))?;
lo = lo.min(*v);
hi = hi.max(*v);
}
let triangle = Triangle::new(vertices)?;
if triangle.geometric_normal().is_none() {
return Err(TraceError::InvalidInput("degenerate dielectric boundary"));
}
*slot = Some(triangle);
}
if !closed {
let first = tris.first().copied().flatten().ok_or(TraceError::InvalidInput("empty thin sheet"))?;
let normal = first.geometric_normal().ok_or(TraceError::InvalidInput("thin sheet normal"))?;
let [anchor, _, _] = first.vertices();
for triangle in tris.into_iter().flatten() {
if triangle.geometric_normal() != Some(normal) || triangle.vertices().into_iter().any(|p| (p - anchor).dot(normal) != 0.0) {
return Err(TraceError::InvalidInput("thin sheet must be one planar consistently wound surface"));
}
}
if let Some(second) = tris.get(1).copied().flatten() {
let first = first.vertices();
let second = second.vertices();
let mut shared = [Vec3::ZERO; 2];
let mut shared_count = 0usize;
let mut other_first = None;
let mut other_second = None;
for p in first {
if second.contains(&p) {
*shared.get_mut(shared_count).ok_or(TraceError::InvalidInput("duplicate thin sheet triangle"))? = p;
shared_count += 1;
} else {
other_first = Some(p);
}
}
for p in second {
if !first.contains(&p) {
other_second = Some(p);
}
}
let [a, b] = shared;
let c = other_first.ok_or(TraceError::InvalidInput("thin sheet shared edge"))?;
let d = other_second.ok_or(TraceError::InvalidInput("thin sheet shared edge"))?;
if shared_count != 2 || (b - a).cross(c - a).dot(normal) * (b - a).cross(d - a).dot(normal) >= 0.0 {
return Err(TraceError::InvalidInput("thin sheet triangles overlap or have no unique shared edge"));
}
}
return Ok(Certificate::ThinSheet);
}
if lo.x >= hi.x || lo.y >= hi.y || lo.z >= hi.z {
return Err(TraceError::InvalidInput("closed box needs positive extents"));
}
let bounds = Bounds::new(lo, hi)?;
let mut masks = [[0u8; 2]; 6];
let mut counts = [0usize; 6];
for triangle in tris.into_iter().flatten() {
let vertices = triangle.vertices();
let n = triangle.geometric_normal().ok_or(TraceError::InvalidInput("box normal"))?;
let axis = if n.x.abs() == 1.0 {
0
} else if n.y.abs() == 1.0 {
1
} else if n.z.abs() == 1.0 {
2
} else {
return Err(TraceError::InvalidInput("box face is not axis aligned"));
};
let positive = super::component(n, axis) > 0.0;
let plane = super::component(if positive { hi } else { lo }, axis);
let face = axis * 2 + usize::from(positive);
let mut mask = 0u8;
for p in vertices {
if super::component(p, axis) != plane {
return Err(TraceError::InvalidInput("box winding or face plane"));
}
let mut corner = 0;
for (bit, a) in [(0, (axis + 1) % 3), (1, (axis + 2) % 3)] {
let value = super::component(p, a);
if value == super::component(hi, a) {
corner |= 1 << bit;
} else if value != super::component(lo, a) {
return Err(TraceError::InvalidInput("box vertex is not a corner"));
}
}
mask |= 1 << corner;
}
let count = counts.get_mut(face).ok_or(TraceError::Internal("box face count"))?;
let target = masks.get_mut(face).and_then(|pair| pair.get_mut(*count)).ok_or(TraceError::InvalidInput("too many box face triangles"))?;
*target = mask;
*count += 1;
}
for (count, [a, b]) in counts.into_iter().zip(masks) {
// Three distinct corners each; missing corners must be opposite. Thus
// both triangles share a diagonal and tile, rather than overlap.
let missing_a = (!a) & 15;
let missing_b = (!b) & 15;
if count != 2
|| a.count_ones() != 3
|| b.count_ones() != 3
|| (a | b) != 15
|| !matches!((missing_a, missing_b), (1, 8) | (8, 1) | (2, 4) | (4, 2))
{
return Err(TraceError::InvalidInput("incomplete or ambiguous closed box face"));
}
}
Ok(Certificate::ClosedBox(bounds))
}
pub fn contains(bounds: Bounds, point: Vec3) -> bool {
let lo = bounds.min();
let hi = bounds.max();
point.x >= lo.x && point.x <= hi.x && point.y >= lo.y && point.y <= hi.y && point.z >= lo.z && point.z <= hi.z
}
pub fn overlaps(a: Bounds, b: Bounds) -> bool {
let lo = a.min().max(b.min());
let hi = a.max().min(b.max());
lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z
}
/// Immediate parents of already-certified axis-aligned boxes. Strict containment
/// is sufficient for this exact box subset; touching and partial overlap refuse.
/// Eight overrides bound this to at most 64 comparisons, without allocation.
pub(super) fn parents(certificates: &[Option<Certificate>; MAX_OVERRIDES], nested: bool) -> Result<[Option<usize>; MAX_OVERRIDES]> {
let mut result = [None; MAX_OVERRIDES];
for (child, certificate) in certificates.iter().enumerate() {
let Some(Certificate::ClosedBox(bounds)) = certificate else { continue };
for (candidate, certificate) in certificates.iter().enumerate() {
let Some(Certificate::ClosedBox(container)) = certificate else { continue };
if candidate == child || !overlaps(*bounds, *container) {
continue;
}
if !nested {
return Err(TraceError::InvalidInput("nested, touching or overlapping dielectric volumes are unsupported"));
}
if strictly_contains(*container, *bounds) {
let parent = result.get_mut(child).ok_or(TraceError::Internal("dielectric child index"))?;
let replace = match *parent {
None => true,
Some(previous) => match certificates.get(previous).copied().flatten() {
Some(Certificate::ClosedBox(old)) => strictly_contains(old, *container),
_ => return Err(TraceError::Internal("dielectric parent certificate")),
},
};
if replace {
*parent = Some(candidate);
}
} else if !strictly_contains(*bounds, *container) {
return Err(TraceError::InvalidInput("dielectric boxes must be disjoint or strictly nested; touching and overlap are unsupported"));
}
}
}
Ok(result)
}
fn strictly_contains(outer: Bounds, inner: Bounds) -> bool {
let lo = outer.min();
let hi = outer.max();
let a = inner.min();
let b = inner.max();
lo.x < a.x && lo.y < a.y && lo.z < a.z && hi.x > b.x && hi.y > b.y && hi.z > b.z
}
/// A certified axis-aligned face point, shared by propagation and Beer intervals.
/// This is used only by the explicit nested-box path, never by general spawn.
#[derive(Clone, Copy)]
pub(super) struct ExactBoxFace {
pub point: Vec3,
axis: usize,
outward: f64,
entering: bool,
}
impl ExactBoxFace {
pub fn spawn(self, direction: Vec3, reflected: bool) -> Result<super::Ray> {
let ray = super::Ray::new(self.point, direction, 0.0_f64.next_up(), super::MAX_DISTANCE_MM)?;
let side = super::component(ray.direction(), self.axis) * self.outward;
let exterior = if reflected { self.entering } else { !self.entering };
if (exterior && side <= 0.0) || (!exterior && side >= 0.0) {
return Err(TraceError::InvalidInput("nested box continuation is tangent or on the wrong side"));
}
Ok(ray)
}
}
/// Recover the exact constant-axis coordinate from the actual certified face.
/// Tangential coordinates are validated, never clamped or displaced. Equal
/// ray/other-face depths conservatively refuse independently of ordinal ties.
pub(super) fn exact_box_face(bounds: Bounds, vertices: [Vec3; 3], normal: Vec3, position: Vec3, incoming: super::Ray) -> Result<ExactBoxFace> {
let mut face = None;
for axis in 0..3 {
let [a, b, c] = vertices.map(|v| super::component(v, axis));
if a == b && b == c {
if face.is_some() {
return Err(TraceError::InvalidInput("nested box face has ambiguous constant axes"));
}
face = Some((axis, a));
}
}
let (axis, plane) = face.ok_or(TraceError::InvalidInput("nested box face is not axis aligned"))?;
let lo = bounds.min();
let hi = bounds.max();
let outward = if plane == super::component(lo, axis) {
-1.0
} else if plane == super::component(hi, axis) {
1.0
} else {
return Err(TraceError::InvalidInput("nested box face disagrees with its certificate"));
};
let axis_vector = match axis {
0 => Vec3::X,
1 => Vec3::Y,
2 => Vec3::Z,
_ => return Err(TraceError::Internal("nested box face axis")),
};
if normal != axis_vector * outward || Triangle::new(vertices)?.geometric_normal() != Some(normal) {
return Err(TraceError::InvalidInput("nested box outward normal disagrees with its certified face"));
}
for vertex in vertices {
for a in 0..3 {
let coordinate = super::component(vertex, a);
if coordinate != super::component(lo, a) && coordinate != super::component(hi, a) {
return Err(TraceError::InvalidInput("nested box face vertex is not a certified corner"));
}
}
}
let point = match axis {
0 => Vec3::new(plane, position.y, position.z),
1 => Vec3::new(position.x, plane, position.z),
2 => Vec3::new(position.x, position.y, plane),
_ => return Err(TraceError::Internal("nested box point axis")),
};
if !point.is_finite() {
return Err(TraceError::InvalidInput("nested box reconstructed point is not finite"));
}
let direction = super::component(incoming.direction(), axis);
if direction == 0.0 {
return Err(TraceError::InvalidInput("nested box incident ray is tangent"));
}
let depth = (plane - super::component(incoming.origin(), axis)) / direction;
let [min, max] = incoming.interval();
if !depth.is_finite() || depth < min || depth > max {
return Err(TraceError::InvalidInput("nested box face is outside the incident ray interval"));
}
for a in 0..3 {
if a == axis {
continue;
}
let coordinate = super::component(point, a);
if coordinate <= super::component(lo, a) || coordinate >= super::component(hi, a) {
return Err(TraceError::InvalidInput("nested box edge, corner or outside-face point is ambiguous"));
}
let d = super::component(incoming.direction(), a);
if d != 0.0 {
for plane in [super::component(lo, a), super::component(hi, a)] {
let other = (plane - super::component(incoming.origin(), a)) / d;
if other == depth {
return Err(TraceError::InvalidInput("nested box simultaneous face depths are ambiguous"));
}
}
}
}
Ok(ExactBoxFace { point, axis, outward, entering: direction * outward < 0.0 })
}
#[cfg(test)]
#[path = "dielectric_tests.rs"]
pub(super) mod tests;
@@ -0,0 +1,737 @@
use super::*;
use crate::{
ProductMesh,
job::{CancelToken, DielectricBoundary, ImageRequest, Integrator, RaytraceOptions, TaskPoll},
};
use solidcraft_geom::Mesh;
use std::sync::Arc;
pub(in crate::raytrace) fn box_body(thickness: f64) -> ProductMesh {
let positions = (0..8)
.map(|i| Vec3::new(if i & 1 == 0 { -10.0 } else { 10.0 }, if i & 2 == 0 { -10.0 } else { 10.0 }, if i & 4 == 0 { 0.0 } else { thickness }))
.collect();
let triangles =
vec![[0, 2, 3], [0, 3, 1], [4, 5, 7], [4, 7, 6], [0, 4, 6], [0, 6, 2], [1, 3, 7], [1, 7, 5], [0, 1, 5], [0, 5, 4], [2, 6, 7], [2, 7, 3]];
ProductMesh {
transmission: None,
name: "captured slab@occurrence-2".into(),
mesh: Arc::new(Mesh { positions, triangles, ..Default::default() }),
color: [255; 3],
opacity: 1.0,
surface: None,
faces: Vec::new(),
}
}
pub(in crate::raytrace) fn scene(body: ProductMesh) -> ProductScene {
let bounds = body.mesh.bounds();
ProductScene { meshes: vec![body], bounds }
}
pub(in crate::raytrace) fn input(boundary: DielectricBoundary) -> DielectricOptions {
DielectricOptions {
body_index: 0,
body_name: "captured slab@occurrence-2".into(),
boundary,
ior: 1.5,
absorption_per_mm: [0.0; 3],
thickness_mm: 0.0,
}
}
fn environment() -> Option<crate::job::RaytraceEnvironmentOptions> {
Some(crate::job::RaytraceEnvironmentOptions { color: [1.0; 3], intensity: 1.0 })
}
pub(in crate::raytrace) fn request(options: RaytraceOptions) -> ImageRequest {
let mut camera = crate::Camera { distance: 50.0, ..Default::default() };
camera.set_view(crate::StandardView::Top);
ImageRequest {
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
integrator: Integrator::Raytrace,
raytrace: Some(options),
camera,
width: 1,
height: 1,
background: [255; 3],
transparent_background: false,
}
}
fn render(scene: ProductScene, options: RaytraceOptions) -> [u8; 4] {
let request = request(options.clone());
let mut task = super::super::image::ImageTask::new(scene, request, CancelToken::default(), options).unwrap();
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
return image.get_pixel(0, 0).0;
}
}
panic!("bounded dielectric fixture failed to finish");
}
#[test]
fn normal_interface_has_four_percent_reflection() {
let hit = interface(-Vec3::Z, Vec3::Z, 1.0, 1.5).unwrap();
assert!((hit.reflectance - 0.04).abs() < 1e-14);
assert_eq!(hit.transmitted, Some(-Vec3::Z));
assert_eq!(hit.reflected, Vec3::Z);
}
#[test]
fn thirty_degree_snell_and_parallel_exit_are_reversible() {
let incident = Vec3::new(0.5, 0.0, -(3.0f64).sqrt() / 2.0);
let enter = interface(incident, Vec3::Z, 1.0, 1.5).unwrap();
let inside = enter.transmitted.unwrap();
assert!((inside.x - 1.0 / 3.0).abs() < 1e-14);
let exit = interface(inside, Vec3::Z, 1.5, 1.0).unwrap();
assert!((exit.transmitted.unwrap() - incident).len() < 1e-14);
assert!((exit.reflectance - enter.reflectance).abs() < 1e-14);
}
#[test]
fn internal_grazing_ray_is_totally_reflected() {
let hit = interface(Vec3::new(0.8, 0.0, -0.6), Vec3::Z, 1.5, 1.0).unwrap();
assert_eq!(hit.transmitted, None);
assert_eq!(hit.reflectance, 1.0);
}
#[test]
fn beer_lambert_uses_actual_millimetre_path() {
let a = attenuation([0.1, 0.2, 0.0], 2.0).unwrap();
let b = attenuation([0.1, 0.2, 0.0], 8.0).unwrap();
assert!((b.x - a.x.powi(4)).abs() < 1e-14);
assert!((b.y - a.y.powi(4)).abs() < 1e-14);
assert_eq!(b.z, 1.0);
}
#[test]
fn hostile_optics_are_refused() {
for v in [f64::NAN, f64::INFINITY, -1.0] {
assert!(interface(-Vec3::Z, Vec3::Z, v, 1.5).is_err());
assert!(attenuation([v, 0.0, 0.0], 1.0).is_err());
assert!(attenuation([0.0; 3], v).is_err());
}
assert!(interface(Vec3::Z, Vec3::Z, 1.0, 1.5).is_err());
assert_eq!(interface(Vec3::X, Vec3::Z, 1.0, 1.0).unwrap().reflectance, 0.0);
}
#[test]
fn closed_box_certification_requires_complete_outward_boundary() {
let original = box_body(2.0);
assert!(matches!(certify(&scene(original.clone()), &input(DielectricBoundary::ClosedBox)), Ok(Certificate::ClosedBox(_))));
let mut body = original.clone();
Arc::make_mut(&mut body.mesh).triangles.pop();
assert!(certify(&scene(body), &input(DielectricBoundary::ClosedBox)).is_err());
let mut body = original.clone();
Arc::make_mut(&mut body.mesh).triangles[0].swap(0, 1);
assert!(certify(&scene(body), &input(DielectricBoundary::ClosedBox)).is_err());
let mut body = original;
let mesh = Arc::make_mut(&mut body.mesh);
mesh.triangles[1] = mesh.triangles[0];
assert!(certify(&scene(body), &input(DielectricBoundary::ClosedBox)).is_err());
}
#[test]
fn captured_occurrence_identity_and_unsupported_boundary_are_refused() {
let scene = scene(box_body(2.0));
let mut material = input(DielectricBoundary::ClosedBox);
material.body_name = "captured slab@occurrence-1".into();
assert!(certify(&scene, &material).is_err());
material = input(DielectricBoundary::ThinSheet);
assert!(certify(&scene, &material).is_err());
}
#[test]
fn actual_png_closed_volume_absorption_uses_two_and_eight_mm() {
for thickness in [2.0, 8.0] {
let mut material = input(DielectricBoundary::ClosedBox);
material.ior = 1.0;
material.absorption_per_mm = [0.1, 0.0, 0.0];
let options = RaytraceOptions {
dielectrics: vec![material],
environment: environment(),
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
let actual = render(scene(box_body(thickness)), options);
let linear = Vec3::new((-0.1 * thickness).exp(), 1.0, 1.0);
let mapped = super::super::lighting::map_to_display(linear, 0.0).unwrap();
let encode = |v: f64| ((if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }) * 255.0).round() as u8;
assert_eq!(actual, [encode(mapped.x), encode(mapped.y), encode(mapped.z), 255]);
}
}
#[test]
fn actual_png_clear_volume_includes_multiple_fresnel_branches() {
let options = RaytraceOptions {
dielectrics: vec![input(DielectricBoundary::ClosedBox)],
environment: environment(),
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
let actual = render(scene(box_body(2.0)), options);
// Constant unit environment, no absorption: all branches sum to almost one.
// Eight boundaries drop only a very small unresolved Fresnel tail.
assert_eq!(actual, [188, 188, 188, 255]);
}
#[test]
fn overlapping_volumes_are_refused_before_pixel_work() {
let mut scene = scene(box_body(2.0));
scene.meshes.push(box_body(2.0));
let first = input(DielectricBoundary::ClosedBox);
let mut second = first.clone();
second.body_index = 1;
let options = RaytraceOptions { dielectrics: vec![first, second], ..Default::default() };
assert!(super::super::image::admit(&scene, &request(options.clone()), &options).is_err());
}
#[test]
fn cancelled_dielectric_job_cannot_publish_png() {
let options = RaytraceOptions { dielectrics: vec![input(DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut task = super::super::image::ImageTask::new(scene(box_body(2.0)), request(options.clone()), cancel.clone(), options).unwrap();
task.step(1).unwrap();
cancel.cancel();
assert_eq!(task.step(1).err().unwrap().code, crate::job::ErrorCode::Cancelled);
}
#[test]
fn runtime_dielectric_inputs_are_bounded_and_default_off() {
assert!(RaytraceOptions::default().dielectrics.is_empty());
let options = RaytraceOptions { dielectrics: vec![input(DielectricBoundary::ClosedBox); 9], ..Default::default() };
assert!(options.validate().is_err());
let mut material = input(DielectricBoundary::ClosedBox);
material.thickness_mm = 1.0;
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
assert!(options.validate().is_err());
let oversized = serde_json::json!({"dielectrics": vec![input(DielectricBoundary::ThinSheet);9]});
assert!(serde_json::from_value::<RaytraceOptions>(oversized).is_err());
assert!(serde_json::from_value::<RaytraceOptions>(serde_json::json!({"dielectrics":[{"body_index":0,"body_name":"x".repeat(1025),"boundary":"closed_box","ior":1.5,"absorption_per_mm":[0,0,0],"thickness_mm":0}]})).is_err());
}
#[test]
fn oblique_parallel_slab_offset_scales_with_actual_thickness() {
let direction = Vec3::new(0.5, 0.0, -(3.0f64).sqrt() / 2.0);
let inside = interface(direction, Vec3::Z, 1.0, 1.5).unwrap().transmitted.unwrap();
let mut offsets = Vec::new();
for thickness in [2.0, 8.0] {
let bottom = Triangle::new([Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 0.0), Vec3::new(10.0, -10.0, 0.0)]).unwrap();
let ray = super::super::Ray::new(Vec3::new(0.0, 0.0, thickness), inside, 0.0, 100.0).unwrap();
let hit = bottom.intersect(&ray).unwrap();
let exit = ray.at(hit.distance).unwrap();
assert!((hit.distance - thickness / -inside.z).abs() < 1e-13);
let undeviated_x = thickness * direction.x / -direction.z;
offsets.push(undeviated_x - exit.x);
}
assert!((offsets[1] - 4.0 * offsets[0]).abs() < 1e-13);
}
#[test]
fn thin_sheet_is_certified_separately_and_duplicate_faces_refuse() {
let mut body = box_body(2.0);
Arc::make_mut(&mut body.mesh).triangles = vec![[4, 5, 7], [4, 7, 6]];
let mut material = input(DielectricBoundary::ThinSheet);
material.ior = 1.0;
material.thickness_mm = 8.0;
material.absorption_per_mm = [0.1, 0.0, 0.0];
assert!(matches!(certify(&scene(body.clone()), &material), Ok(Certificate::ThinSheet)));
let options =
RaytraceOptions { dielectrics: vec![material], environment: environment(), lights: Some(Vec::new()), shadows: false, ..Default::default() };
let actual = render(scene(body.clone()), options);
let closed = RaytraceOptions {
environment: environment(),
dielectrics: vec![DielectricOptions { ior: 1.0, absorption_per_mm: [0.1, 0.0, 0.0], ..input(DielectricBoundary::ClosedBox) }],
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
assert_eq!(actual, render(scene(box_body(8.0)), closed));
let mesh = Arc::make_mut(&mut body.mesh);
mesh.triangles[1] = mesh.triangles[0];
assert!(certify(&scene(body), &input(DielectricBoundary::ThinSheet)).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_dielectric_job_uses_existing_host_and_retained_png_lease() {
use crate::job::{CpuHost, JobId, Phase};
let host = CpuHost::for_platform().unwrap();
let options = RaytraceOptions {
dielectrics: vec![input(DielectricBoundary::ClosedBox)],
environment: environment(),
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
let scene = scene(box_body(2.0));
let weak = Arc::downgrade(&scene.meshes[0].mesh);
let id = JobId { session: 1655, serial: 1, generation: 1 };
let handle = host.submit(id, 1, scene, request(options)).unwrap();
assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
let frame = handle.frame().unwrap().unwrap();
assert!(::image::load_from_memory(frame.png()).is_ok());
assert!(weak.upgrade().is_none());
assert!(host.admitted_bytes() > 0);
drop(handle);
assert!(host.admitted_bytes() > 0);
drop(frame);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while host.admitted_bytes() != 0 {
assert!(std::time::Instant::now() < deadline, "worker did not release terminal request record");
std::thread::yield_now();
}
}
#[test]
fn secondary_environment_is_explicit_and_primary_background_remains_display_srgb() {
let options =
RaytraceOptions { dielectrics: vec![input(DielectricBoundary::ClosedBox)], lights: Some(Vec::new()), shadows: false, ..Default::default() };
assert_eq!(render(scene(box_body(2.0)), options.clone()), [0, 0, 0, 255]);
let mut request = request(options.clone());
request.camera.target.x = 50.0;
request.background = [20, 30, 40];
request.raytrace.as_mut().unwrap().exposure_ev = 4.0;
let options = request.raytrace.clone().unwrap();
let mut task = super::super::image::ImageTask::new(scene(box_body(2.0)), request, CancelToken::default(), options).unwrap();
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
assert_eq!(::image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0, [20, 30, 40, 255]);
return;
}
}
panic!("primary background fixture failed to finish");
}
#[test]
fn typed_runtime_roundtrip_preserves_default_empty_and_explicit_lists() {
for options in [
RaytraceOptions::default(),
RaytraceOptions { lights: Some(Vec::new()), ..Default::default() },
RaytraceOptions { dielectrics: vec![input(DielectricBoundary::ClosedBox)], environment: environment(), ..Default::default() },
] {
let encoded = serde_json::to_value(&options).unwrap();
let decoded: RaytraceOptions = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, options);
assert!(decoded.validate().is_ok());
}
}
#[test]
fn actual_oblique_png_selects_the_snell_shifted_colour_target() {
fn target(name: &str, y0: f64, y1: f64, color: Vec3) -> ProductMesh {
ProductMesh {
transmission: None,
name: name.into(),
mesh: Arc::new(Mesh {
positions: vec![Vec3::new(-20.0, y0, -1.0), Vec3::new(20.0, y0, -1.0), Vec3::new(20.0, y1, -1.0), Vec3::new(-20.0, y1, -1.0)],
normals: vec![Vec3::Z; 4],
triangles: vec![[0, 1, 2], [0, 2, 3]],
..Default::default()
}),
color: [255; 3],
opacity: 1.0,
surface: Some(crate::surface::OpaqueSurface::new(color, 0.0, 1.0, 1.0).unwrap()),
faces: Vec::new(),
}
}
let mut scene = scene(box_body(8.0));
scene.meshes.push(target("near blue stripe", -20.0, 4.0, Vec3::Z));
scene.meshes.push(target("far red stripe", 4.0, 20.0, Vec3::X));
for body in &scene.meshes {
scene.bounds = scene.bounds.union(&body.mesh.bounds());
}
// Entry at (0,0,8), incidence30deg. Independent Snell values give
// y=8/sqrt(8)+1/sqrt(3)=3.4058 for glass and 9/sqrt(3)=5.1962
// for index-matched air at target z=-1, on opposite sides of y=4.
let shifted_y = 8.0 / 8.0f64.sqrt() + 1.0 / 3.0f64.sqrt();
let straight_y = 9.0 / 3.0f64.sqrt();
assert!(shifted_y < 4.0 && straight_y > 4.0);
let mut results = Vec::new();
for ior in [1.5, 1.0] {
let options = RaytraceOptions {
dielectrics: vec![DielectricOptions { ior, ..input(DielectricBoundary::ClosedBox) }],
lights: Some(vec![crate::job::DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 4.0 }]),
shadows: false,
..Default::default()
};
let mut request = request(options.clone());
request.camera.yaw = 0.0;
request.camera.pitch = std::f64::consts::PI / 3.0;
request.camera.target = Vec3::new(0.0, 0.0, 8.0);
let mut task = super::super::image::ImageTask::new(scene.clone(), request, CancelToken::default(), options).unwrap();
let mut pixel = None;
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
pixel = Some(::image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0);
break;
}
}
results.push(pixel.expect("oblique slab fixture must finish"));
}
let glass = results[0];
let matched = results[1];
assert!(u16::from(glass[2]) > u16::from(glass[0]) + 50, "glass ray missed the predicted blue stripe: {glass:?}");
assert!(u16::from(matched[0]) > u16::from(matched[2]) + 50, "index-matched ray missed the predicted red stripe: {matched:?}");
}
#[test]
fn thin_sheet_brewster_sums_polarizations_before_averaging() {
// tan(theta_B) = 3/2, so single-interface Rs = 25/169 and Rp = 0.
// The lossless slab returns Rs/(1+Rs) after the unpolarized average.
let direction = Vec3::new(3.0, 0.0, -2.0) / 13.0f64.sqrt();
let material = input(DielectricBoundary::ThinSheet);
let (_, polarized) = polarized_interface(direction, Vec3::Z, 1.0, 1.5).unwrap();
assert!((polarized[0] - 25.0 / 169.0).abs() < 1e-14);
assert!(polarized[1].abs() < 1e-28);
let (optics, reflected, transmitted) = thin_sheet(direction, Vec3::Z, &material).unwrap();
let expected_r = 25.0 / 194.0;
let expected_t = 169.0 / 194.0;
assert!((reflected - Vec3::new(expected_r, expected_r, expected_r)).len() < 1e-14);
assert!((transmitted - Vec3::new(expected_t, expected_t, expected_t)).len() < 1e-14);
assert!((optics.transmitted.unwrap() - direction).len() < 1e-14);
// The old average-before-series result differs by this exact rational.
assert!((50.0 / 363.0 - reflected.x - 625.0 / 70422.0).abs() < 1e-14);
}
#[test]
fn thin_sheet_normal_incidence_keeps_the_lossless_slab_limit() {
let material = input(DielectricBoundary::ThinSheet);
let (_, reflected, transmitted) = thin_sheet(-Vec3::Z, Vec3::Z, &material).unwrap();
// Rs = Rp = 1/25: the two-interface lossless slab has R = 1/13.
let r = 1.0 / 13.0;
let t = 12.0 / 13.0;
assert!((reflected - Vec3::new(r, r, r)).len() < 1e-14);
assert!((transmitted - Vec3::new(t, t, t)).len() < 1e-14);
}
#[test]
fn thin_sheet_matched_indices_keep_beer_lambert_transmission() {
let material = DielectricOptions {
ior: 1.0,
thickness_mm: 2.0,
absorption_per_mm: [2.0f64.ln() / 2.0, 4.0f64.ln() / 2.0, 0.0],
..input(DielectricBoundary::ThinSheet)
};
let (optics, reflected, transmitted) = thin_sheet(-Vec3::Z, Vec3::Z, &material).unwrap();
assert_eq!(reflected, Vec3::ZERO);
assert!((transmitted - Vec3::new(0.5, 0.25, 1.0)).len() < 1e-14);
assert_eq!(optics.transmitted, Some(-Vec3::Z));
}
#[test]
fn thin_sheet_zero_thickness_index_matched_grazing_is_transparent() {
// Equal indices remove the interface; zero thickness means exp(-sigma * 0) = 1.
for absorption_per_mm in [[0.0; 3], [100.0, 0.25, 1.0]] {
let material = DielectricOptions { ior: 1.0, absorption_per_mm, ..input(DielectricBoundary::ThinSheet) };
let (optics, reflected, transmitted) = thin_sheet(Vec3::X, Vec3::Z, &material).unwrap();
assert_eq!(optics.reflectance, 0.0);
assert_eq!(optics.transmitted, Some(Vec3::X));
assert_eq!(reflected, Vec3::ZERO);
assert_eq!(transmitted, Vec3::new(1.0, 1.0, 1.0));
}
}
#[test]
fn thin_sheet_positive_thickness_index_matched_grazing_refuses_without_tir() {
let material = DielectricOptions { ior: 1.0, thickness_mm: 1.0, ..input(DielectricBoundary::ThinSheet) };
let optics = interface(Vec3::X, Vec3::Z, 1.0, 1.0).unwrap();
assert_eq!(optics.transmitted, Some(Vec3::X));
assert_eq!(optics.reflectance, 0.0);
assert!(matches!(thin_sheet(Vec3::X, Vec3::Z, &material), Err(TraceError::InvalidInput("grazing sheet thickness is unsupported"))));
}
#[test]
fn thin_sheet_zero_thickness_grazing_still_validates_absorption() {
for sigma in [f64::NAN, f64::INFINITY, -1.0, 101.0] {
let material = DielectricOptions { ior: 1.0, absorption_per_mm: [sigma, 0.0, 0.0], ..input(DielectricBoundary::ThinSheet) };
assert!(matches!(thin_sheet(Vec3::X, Vec3::Z, &material), Err(TraceError::InvalidInput("dielectric absorption or distance"))));
}
}
#[test]
fn thin_sheet_absorbing_brewster_keeps_separate_internal_returns() {
let direction = Vec3::new(3.0, 0.0, -2.0) / 13.0f64.sqrt();
let material = DielectricOptions {
// cos(theta_t) = 3/sqrt(13), hence the one-pass distance is 1 mm.
thickness_mm: 3.0 / 13.0f64.sqrt(),
absorption_per_mm: [2.0f64.ln(), 2.0f64.ln(), 0.0],
..input(DielectricBoundary::ThinSheet)
};
let (_, reflected, transmitted) = thin_sheet(direction, Vec3::Z, &material).unwrap();
// Independent rational series with Rs = 25/169, Rp = 0, a = 1/2.
let r = 6625.0 / 75746.0;
let t = 65521.0 / 151492.0;
assert!((reflected - Vec3::new(r, r, 25.0 / 194.0)).len() < 1e-14);
assert!((transmitted - Vec3::new(t, t, 169.0 / 194.0)).len() < 1e-14);
assert!(reflected.x + transmitted.x < 1.0);
}
#[test]
fn actual_png_brewster_thin_sheet_matches_reflected_energy() {
let mut sheet = box_body(2.0);
Arc::make_mut(&mut sheet.mesh).triangles.retain(|t| *t == [4, 5, 7] || *t == [4, 7, 6]);
let mut scene = scene(sheet);
let absorber = ProductMesh {
transmission: None,
name: "unlit opaque transmission target".into(),
mesh: Arc::new(Mesh {
positions: vec![Vec3::new(-20.0, -20.0, -1.0), Vec3::new(20.0, -20.0, -1.0), Vec3::new(20.0, 20.0, -1.0), Vec3::new(-20.0, 20.0, -1.0)],
normals: vec![Vec3::Z; 4],
triangles: vec![[0, 1, 2], [0, 2, 3]],
..Default::default()
}),
color: [0; 3],
opacity: 1.0,
surface: None,
faces: Vec::new(),
};
scene.bounds = scene.bounds.union(&absorber.mesh.bounds());
scene.meshes.push(absorber);
let options = RaytraceOptions {
dielectrics: vec![input(DielectricBoundary::ThinSheet)],
environment: environment(),
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
let mut request = request(options.clone());
request.camera.yaw = 0.0;
request.camera.pitch = (2.0f64 / 3.0).atan();
request.camera.target = Vec3::new(0.0, 0.0, 2.0);
let (_, direction) = request.camera.ray(0.5, 0.5, 1.0, 1.0);
assert!((-direction.z - 2.0 / 13.0f64.sqrt()).abs() < 1e-14);
let mut task = super::super::image::ImageTask::new(scene, request, CancelToken::default(), options).unwrap();
let mut pixel = None;
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
pixel = Some(::image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0);
break;
}
}
// Only the reflected branch sees the unit white environment. R = 25/194
// maps through Reinhard to 25/219, then sRGB to 95. The old R = 50/363
// maps to 98, so this also detects a consumer still using the old series.
assert_eq!(pixel.expect("Brewster sheet fixture must finish"), [95, 95, 95, 255]);
}
#[test]
fn strict_box_parent_selection_is_order_independent_and_opt_in() {
let bounds = |lo: f64, hi: f64| Bounds::new(Vec3::new(lo, lo, lo), Vec3::new(hi, hi, hi)).unwrap();
let mut certificates = [None; MAX_OVERRIDES];
certificates[0] = Some(Certificate::ClosedBox(bounds(2.0, 3.0)));
certificates[1] = Some(Certificate::ClosedBox(bounds(0.0, 5.0)));
certificates[2] = Some(Certificate::ClosedBox(bounds(1.0, 4.0)));
assert!(parents(&certificates, false).is_err());
assert_eq!(parents(&certificates, true).unwrap(), [Some(2), None, Some(1), None, None, None, None, None]);
certificates.swap(0, 1);
assert_eq!(parents(&certificates, true).unwrap(), [None, Some(2), Some(0), None, None, None, None, None]);
}
#[test]
fn nesting_opt_in_still_refuses_touching_equal_and_partially_overlapping_boxes() {
let outer = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
for (lo, hi) in [
(Vec3::new(0.0, 1.0, 1.0), Vec3::new(3.0, 3.0, 3.0)),
(Vec3::new(4.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
(Vec3::new(4.0, 4.0, 1.0), Vec3::new(5.0, 5.0, 3.0)),
(Vec3::new(4.0, 4.0, 4.0), Vec3::new(5.0, 5.0, 5.0)),
(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)),
(Vec3::new(3.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
] {
let mut certificates = [None; MAX_OVERRIDES];
certificates[0] = Some(Certificate::ClosedBox(outer));
certificates[1] = Some(Certificate::ClosedBox(Bounds::new(lo, hi).unwrap()));
assert!(parents(&certificates, true).is_err());
}
}
pub(in crate::raytrace) fn nested_fixture(reverse: bool) -> (ProductScene, RaytraceOptions) {
let mut outer = box_body(4.0);
outer.name = "Outer".into();
let mut inner = box_body(2.0);
inner.name = "Inner".into();
for p in &mut Arc::make_mut(&mut inner.mesh).positions {
*p = Vec3::new(p.x * 0.5, p.y * 0.5, p.z + 1.0);
}
let mut scene = scene(outer);
scene.meshes.push(inner);
if reverse {
scene.meshes.reverse();
}
let dielectrics = scene
.meshes
.iter()
.enumerate()
.map(|(body_index, body)| DielectricOptions {
body_index,
body_name: body.name.clone(),
boundary: DielectricBoundary::ClosedBox,
ior: 1.0,
absorption_per_mm: if body.name == "Outer" { [0.1, 0.2, 0.0] } else { [0.3, 0.4, 0.0] },
thickness_mm: 0.0,
})
.collect();
(scene, RaytraceOptions { dielectrics, environment: environment(), lights: Some(Vec::new()), shadows: false, ..Default::default() })
}
#[test]
fn actual_nested_png_uses_parent_only_outside_the_inner_interval() {
for reverse in [false, true] {
let (scene, options) = nested_fixture(reverse);
assert!(super::super::image::admit(&scene, &request(options.clone()), &options).is_err());
let mut task = super::super::image::ImageTask::new_with_environment_and_nested(
scene,
request(options.clone()),
CancelToken::default(),
options,
None,
true,
)
.unwrap();
let mut completed = false;
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
// Outer length2, inner length2; Beer radiance (exp(-.8), exp(-1.2), 1).
// Reinhard then IEC sRGB encoding gives these independently fixed bytes.
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
completed = true;
break;
}
}
assert!(completed, "nested final image did not complete");
}
}
#[test]
fn progressive_optics_keep_the_nested_certificate_opt_in_and_interactive_intent() {
let (scene, mut options) = nested_fixture(false);
options.progressive = Some(Default::default());
options.validate().unwrap();
let request = request(options.clone());
// A DTO succeeding never grants a nesting certificate to an ordinary job.
assert!(super::super::image::admit(&scene, &request, &options).is_err());
assert!(super::super::image::admit_with_nested(&scene, &request, &options, true).is_ok());
let interactive = crate::job::interactive::cpu_request(&request).unwrap();
assert_eq!(interactive.raytrace.as_ref().unwrap().dielectrics, options.dielectrics);
assert!(super::super::image::admit(&scene, &interactive, interactive.raytrace.as_ref().unwrap()).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_host_forwards_only_the_existing_nested_media_exception() {
use crate::job::{CpuHost, ErrorCode, JobId, JobOptions, Phase};
let host = CpuHost::for_platform().unwrap();
let (scene, options) = nested_fixture(false);
let request = request(options);
let nested = JobOptions { nested_media: true, ..Default::default() };
assert!(host.admit(&scene, &request, JobOptions::default()).is_err());
assert!(host.admit(&scene, &request, nested).is_ok());
for bad in [
JobOptions { samples: 2, ..nested },
JobOptions { max_depth: 2, ..nested },
JobOptions { seed: 7, ..nested },
JobOptions { exposure_ev: 1.0, ..nested },
JobOptions { transmission_samples: 2, ..nested },
JobOptions { denoise: true, ..nested },
JobOptions { linear_exr: true, ..nested },
] {
assert_eq!(host.admit(&scene, &request, bad).unwrap_err().code, ErrorCode::InvalidRequest);
}
let mut raster = request.clone();
raster.integrator = Integrator::Raster;
raster.raytrace = None;
assert!(host.admit(&scene, &raster, nested).is_err());
let handle = host.submit_with_options(JobId { session: 6302, serial: 1, generation: 1 }, 1, scene, request, nested).unwrap();
assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
assert_eq!(handle.status().unwrap().options.nested_media, true);
let frame = handle.frame().unwrap().unwrap();
let image = ::image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
}
fn face_fixture(axis: usize, positive: bool) -> (Bounds, [Vec3; 3], Vec3, Vec3) {
let point = |a: f64, u: f64, v: f64| match axis {
0 => Vec3::new(a, u, v),
1 => Vec3::new(v, a, u),
2 => Vec3::new(u, v, a),
_ => panic!("test axis"),
};
let bounds = Bounds::new(Vec3::new(1.0, 1.0, 1.0), Vec3::new(4.0, 4.0, 4.0)).unwrap();
let plane = if positive { 4.0 } else { 1.0 };
let mut vertices = [point(plane, 1.0, 1.0), point(plane, 4.0, 1.0), point(plane, 4.0, 4.0)];
if !positive {
vertices.swap(1, 2);
}
let normal = match axis {
0 => Vec3::X,
1 => Vec3::Y,
2 => Vec3::Z,
_ => panic!("test axis"),
} * if positive { 1.0 } else { -1.0 };
(bounds, vertices, normal, point(plane, 2.5, 2.5))
}
#[test]
fn exact_face_excludes_own_zero_depth_and_retains_one_ulp_neighbour_on_every_axis_and_side() {
use super::super::Ray;
for axis in 0..3 {
for positive in [false, true] {
let (bounds, vertices, normal, position) = face_fixture(axis, positive);
for entering in [false, true] {
let direction = if entering { -normal } else { normal };
let incoming = Ray::new(position - direction, direction, 0.0, 10.0).unwrap();
let face = exact_box_face(bounds, vertices, normal, position, incoming).unwrap();
assert_eq!(face.point, position, "face diagonal is valid interior");
let optics = interface(direction, -direction, if entering { 1.0 } else { 1.5 }, if entering { 1.5 } else { 1.0 }).unwrap();
let reflected = face.spawn(optics.reflected, true).unwrap();
let transmitted = face.spawn(optics.transmitted.unwrap(), false).unwrap();
let triangle = Triangle::new(vertices).unwrap();
assert!(triangle.intersect(&reflected).is_none(), "own reflected face at t0 must be excluded");
assert!(triangle.intersect(&transmitted).is_none(), "own transmitted face at t0 must be excluded");
assert_eq!(reflected.origin(), face.point);
assert_eq!(transmitted.origin(), face.point);
assert_eq!(transmitted.interval()[0], 0.0_f64.next_up());
let plane = super::super::component(position, axis);
let next = if super::super::component(direction, axis) > 0.0 { plane.next_up() } else { plane.next_down() };
let neighbour = vertices.map(|v| match axis {
0 => Vec3::new(next, v.y, v.z),
1 => Vec3::new(v.x, next, v.z),
2 => Vec3::new(v.x, v.y, next),
_ => panic!("test axis"),
});
let hit = Triangle::new(neighbour).unwrap().intersect(&transmitted).expect("one-ULP positive face must remain ahead");
assert_eq!(hit.distance, (next - plane).abs());
}
}
}
}
#[test]
fn exact_face_recovers_certified_plane_without_clamping_or_blessing_ties() {
use super::super::Ray;
let (bounds, vertices, normal, point) = face_fixture(2, false);
let incoming = Ray::new(point - Vec3::Z, Vec3::Z, 0.0, 10.0).unwrap();
let rounded = Vec3::new(point.x, point.y, point.z.next_up());
let face = exact_box_face(bounds, vertices, normal, rounded, incoming).unwrap();
assert_eq!(face.point, point);
for bad in [Vec3::new(1.0, 2.5, 1.0), Vec3::new(1.0, 1.0, 1.0), Vec3::new(1.0_f64.next_down(), 2.5, 1.0), Vec3::new(f64::NAN, 2.5, 1.0)] {
assert!(exact_box_face(bounds, vertices, normal, bad, incoming).is_err());
}
let tied = Ray::new(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0), 0.0, 10.0).unwrap();
// Even if rounded tangential reconstruction claims interior, independently
// equal actual ray/face depths cannot inherit the BVH ordinal's blessing.
let uncertain = Vec3::new(1.0_f64.next_up(), 1.0_f64.next_up(), 1.0);
assert!(exact_box_face(bounds, vertices, normal, uncertain, tied).is_err());
assert!(exact_box_face(bounds, vertices, -normal, point, incoming).is_err());
let other_bounds = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
assert!(exact_box_face(other_bounds, vertices, normal, point, incoming).is_err());
let tangent = Ray::new(point - Vec3::X, Vec3::X, 0.0, 10.0).unwrap();
assert!(exact_box_face(bounds, vertices, normal, point, tangent).is_err());
assert!(face.spawn(Vec3::X, true).is_err());
assert!(face.spawn(-Vec3::Z, false).is_err());
assert!(face.spawn(Vec3::Z, true).is_err());
}
@@ -0,0 +1,636 @@
//! Original bounded smooth dielectric optics; no renderer source adapted.
//! Air is 1.0. Distances and absorption coefficients use millimetres.
use super::{Bounds, Result, TraceError, Triangle, unit};
use crate::{ProductScene, job::DielectricOptions};
use solidcraft_geom::Vec3;
pub const MAX_OVERRIDES: usize = 8;
pub const MAX_BOUNDARIES: u8 = 8;
pub const MAX_PATHS: u16 = 511;
#[derive(Clone, Copy, Debug)]
pub struct Interface {
pub reflected: Vec3,
pub transmitted: Option<Vec3>,
pub reflectance: f64,
}
/// Exact unpolarized dielectric Fresnel and Snell, with the normal towards
/// the incident medium. Equal indices have no interface, including grazing.
pub fn interface(direction: Vec3, normal: Vec3, incident: f64, transmitted: f64) -> Result<Interface> {
polarized_interface(direction, normal, incident, transmitted).map(|(optics, _)| optics)
}
fn polarized_interface(direction: Vec3, normal: Vec3, incident: f64, transmitted: f64) -> Result<(Interface, [f64; 2])> {
if ![incident, transmitted].into_iter().all(|n| n.is_finite() && (1.0..=3.0).contains(&n)) {
return Err(TraceError::InvalidInput("dielectric IOR outside 1..3"));
}
let d = unit(direction).ok_or(TraceError::InvalidInput("dielectric direction"))?;
let n = unit(normal).ok_or(TraceError::InvalidInput("dielectric normal"))?;
let cosine = -d.dot(n);
if cosine < 0.0 {
return Err(TraceError::InvalidInput("dielectric incident hemisphere"));
}
let cosine = cosine.clamp(0.0, 1.0);
let reflected = unit(d + n * (2.0 * cosine)).ok_or(TraceError::InvalidInput("reflected direction"))?;
if incident == transmitted {
return Ok((Interface { reflected, transmitted: Some(d), reflectance: 0.0 }, [0.0; 2]));
}
let ratio = incident / transmitted;
let sine2 = ratio * ratio * (1.0 - cosine * cosine).max(0.0);
if sine2 >= 1.0 {
return Ok((Interface { reflected, transmitted: None, reflectance: 1.0 }, [1.0; 2]));
}
let cos_t = (1.0 - sine2).sqrt();
let rs = (incident * cosine - transmitted * cos_t) / (incident * cosine + transmitted * cos_t);
let rp = (transmitted * cosine - incident * cos_t) / (transmitted * cosine + incident * cos_t);
let refracted = unit(d * ratio + n * (ratio * cosine - cos_t)).ok_or(TraceError::InvalidInput("refracted direction"))?;
let polarized = [(rs * rs).clamp(0.0, 1.0), (rp * rp).clamp(0.0, 1.0)];
Ok((Interface { reflected, transmitted: Some(refracted), reflectance: (polarized[0] + polarized[1]) * 0.5 }, polarized))
}
/// Incoherent parallel-sheet returns for unpolarized incident light. Sum each
/// polarization's internal returns before averaging; virtual thickness affects
/// absorption only. This closure does not transport Stokes state between hits.
pub(super) fn thin_sheet(direction: Vec3, normal: Vec3, material: &DielectricOptions) -> Result<(Interface, Vec3, Vec3)> {
let direction = unit(direction).ok_or(TraceError::InvalidInput("dielectric direction"))?;
let normal = unit(normal).ok_or(TraceError::InvalidInput("dielectric normal"))?;
let (optics, polarized) = polarized_interface(direction, normal, 1.0, material.ior)?;
let inside = optics.transmitted.ok_or(TraceError::InvalidInput("air to sheet transmission"))?;
let cos_t = -inside.dot(normal);
// Zero virtual thickness has zero optical distance, including the index-matched
// grazing limit. A positive thickness with no crossing direction is undefined.
let distance = if material.thickness_mm == 0.0 {
0.0
} else if cos_t > 0.0 {
material.thickness_mm / cos_t
} else {
return Err(TraceError::InvalidInput("grazing sheet thickness is unsupported"));
};
let a = attenuation(material.absorption_per_mm, distance)?;
let weights = |a: f64| {
let series = |f: f64| {
let denominator = 1.0 - f * f * a * a;
if denominator == 0.0 { (1.0, 0.0) } else { (f + (1.0 - f).powi(2) * f * a * a / denominator, (1.0 - f).powi(2) * a / denominator) }
};
let s = series(polarized[0]);
let p = series(polarized[1]);
((s.0 + p.0) * 0.5, (s.1 + p.1) * 0.5)
};
let (rr, tr) = weights(a.x);
let (rg, tg) = weights(a.y);
let (rb, tb) = weights(a.z);
Ok((Interface { transmitted: Some(direction), ..optics }, Vec3::new(rr, rg, rb), Vec3::new(tr, tg, tb)))
}
pub fn attenuation(sigma: [f64; 3], distance_mm: f64) -> Result<Vec3> {
if !distance_mm.is_finite()
|| !(0.0..=super::MAX_DISTANCE_MM).contains(&distance_mm)
|| !sigma.into_iter().all(|v| v.is_finite() && (0.0..=100.0).contains(&v))
{
return Err(TraceError::InvalidInput("dielectric absorption or distance"));
}
let [r, g, b] = sigma.map(|s| (-s * distance_mm).exp());
Ok(Vec3::new(r, g, b))
}
pub fn multiply(a: Vec3, b: Vec3) -> Vec3 {
Vec3::new(a.x * b.x, a.y * b.y, a.z * b.z)
}
#[derive(Clone, Copy, Debug)]
pub enum Certificate {
ThinSheet,
ClosedBox(Bounds),
}
/// A strict geometric subset, never a trusted mesh.closed flag. Only twelve
/// outward corner triangles, two complementary halves per box face, qualify.
pub fn certify(scene: &ProductScene, input: &DielectricOptions) -> Result<Certificate> {
let body = scene.meshes.get(input.body_index).ok_or(TraceError::InvalidInput("dielectric body ordinal"))?;
if body.name != input.body_name {
return Err(TraceError::InvalidInput("dielectric captured body identity mismatch"));
}
let mesh = &body.mesh;
let closed = matches!(input.boundary, crate::job::DielectricBoundary::ClosedBox);
if (!closed && !(1..=2).contains(&mesh.triangles.len())) || (closed && mesh.triangles.len() != 12) || mesh.positions.len() > 36 {
return Err(TraceError::InvalidInput("unsupported dielectric boundary tessellation"));
}
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = -lo;
let mut tris = [None; 12];
for (slot, indices) in tris.iter_mut().zip(&mesh.triangles) {
let mut vertices = [Vec3::ZERO; 3];
for (v, index) in vertices.iter_mut().zip(indices) {
*v = *mesh.positions.get(*index as usize).ok_or(TraceError::InvalidInput("dielectric vertex index"))?;
lo = lo.min(*v);
hi = hi.max(*v);
}
let triangle = Triangle::new(vertices)?;
if triangle.geometric_normal().is_none() {
return Err(TraceError::InvalidInput("degenerate dielectric boundary"));
}
*slot = Some(triangle);
}
if !closed {
let first = tris.first().copied().flatten().ok_or(TraceError::InvalidInput("empty thin sheet"))?;
let normal = first.geometric_normal().ok_or(TraceError::InvalidInput("thin sheet normal"))?;
let [anchor, _, _] = first.vertices();
for triangle in tris.into_iter().flatten() {
if triangle.geometric_normal() != Some(normal) || triangle.vertices().into_iter().any(|p| (p - anchor).dot(normal) != 0.0) {
return Err(TraceError::InvalidInput("thin sheet must be one planar consistently wound surface"));
}
}
if let Some(second) = tris.get(1).copied().flatten() {
let first = first.vertices();
let second = second.vertices();
let mut shared = [Vec3::ZERO; 2];
let mut shared_count = 0usize;
let mut other_first = None;
let mut other_second = None;
for p in first {
if second.contains(&p) {
*shared.get_mut(shared_count).ok_or(TraceError::InvalidInput("duplicate thin sheet triangle"))? = p;
shared_count += 1;
} else {
other_first = Some(p);
}
}
for p in second {
if !first.contains(&p) {
other_second = Some(p);
}
}
let [a, b] = shared;
let c = other_first.ok_or(TraceError::InvalidInput("thin sheet shared edge"))?;
let d = other_second.ok_or(TraceError::InvalidInput("thin sheet shared edge"))?;
if shared_count != 2 || (b - a).cross(c - a).dot(normal) * (b - a).cross(d - a).dot(normal) >= 0.0 {
return Err(TraceError::InvalidInput("thin sheet triangles overlap or have no unique shared edge"));
}
}
return Ok(Certificate::ThinSheet);
}
if lo.x >= hi.x || lo.y >= hi.y || lo.z >= hi.z {
return Err(TraceError::InvalidInput("closed box needs positive extents"));
}
let bounds = Bounds::new(lo, hi)?;
let mut masks = [[0u8; 2]; 6];
let mut counts = [0usize; 6];
for triangle in tris.into_iter().flatten() {
let vertices = triangle.vertices();
let n = triangle.geometric_normal().ok_or(TraceError::InvalidInput("box normal"))?;
let axis = if n.x.abs() == 1.0 {
0
} else if n.y.abs() == 1.0 {
1
} else if n.z.abs() == 1.0 {
2
} else {
return Err(TraceError::InvalidInput("box face is not axis aligned"));
};
let positive = super::component(n, axis) > 0.0;
let plane = super::component(if positive { hi } else { lo }, axis);
let face = axis * 2 + usize::from(positive);
let mut mask = 0u8;
for p in vertices {
if super::component(p, axis) != plane {
return Err(TraceError::InvalidInput("box winding or face plane"));
}
let mut corner = 0;
for (bit, a) in [(0, (axis + 1) % 3), (1, (axis + 2) % 3)] {
let value = super::component(p, a);
if value == super::component(hi, a) {
corner |= 1 << bit;
} else if value != super::component(lo, a) {
return Err(TraceError::InvalidInput("box vertex is not a corner"));
}
}
mask |= 1 << corner;
}
let count = counts.get_mut(face).ok_or(TraceError::Internal("box face count"))?;
let target = masks.get_mut(face).and_then(|pair| pair.get_mut(*count)).ok_or(TraceError::InvalidInput("too many box face triangles"))?;
*target = mask;
*count += 1;
}
for (count, [a, b]) in counts.into_iter().zip(masks) {
// Three distinct corners each; missing corners must be opposite. Thus
// both triangles share a diagonal and tile, rather than overlap.
let missing_a = (!a) & 15;
let missing_b = (!b) & 15;
if count != 2
|| a.count_ones() != 3
|| b.count_ones() != 3
|| (a | b) != 15
|| !matches!((missing_a, missing_b), (1, 8) | (8, 1) | (2, 4) | (4, 2))
{
return Err(TraceError::InvalidInput("incomplete or ambiguous closed box face"));
}
}
Ok(Certificate::ClosedBox(bounds))
}
pub fn contains(bounds: Bounds, point: Vec3) -> bool {
let lo = bounds.min();
let hi = bounds.max();
point.x >= lo.x && point.x <= hi.x && point.y >= lo.y && point.y <= hi.y && point.z >= lo.z && point.z <= hi.z
}
pub fn overlaps(a: Bounds, b: Bounds) -> bool {
let lo = a.min().max(b.min());
let hi = a.max().min(b.max());
lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z
}
/// Immediate parents of already-certified axis-aligned boxes. Strict containment
/// is sufficient for this exact box subset; touching and partial overlap refuse.
/// Eight overrides bound this to at most 64 comparisons, without allocation.
pub(super) fn parents(certificates: &[Option<Certificate>; MAX_OVERRIDES], nested: bool) -> Result<[Option<usize>; MAX_OVERRIDES]> {
let mut result = [None; MAX_OVERRIDES];
for (child, certificate) in certificates.iter().enumerate() {
let Some(Certificate::ClosedBox(bounds)) = certificate else { continue };
for (candidate, certificate) in certificates.iter().enumerate() {
let Some(Certificate::ClosedBox(container)) = certificate else { continue };
if candidate == child || !overlaps(*bounds, *container) {
continue;
}
if !nested {
return Err(TraceError::InvalidInput("nested, touching or overlapping dielectric volumes are unsupported"));
}
if strictly_contains(*container, *bounds) {
let parent = result.get_mut(child).ok_or(TraceError::Internal("dielectric child index"))?;
let replace = match *parent {
None => true,
Some(previous) => match certificates.get(previous).copied().flatten() {
Some(Certificate::ClosedBox(old)) => strictly_contains(old, *container),
_ => return Err(TraceError::Internal("dielectric parent certificate")),
},
};
if replace {
*parent = Some(candidate);
}
} else if !strictly_contains(*bounds, *container) {
return Err(TraceError::InvalidInput("dielectric boxes must be disjoint or strictly nested; touching and overlap are unsupported"));
}
}
}
Ok(result)
}
fn strictly_contains(outer: Bounds, inner: Bounds) -> bool {
let lo = outer.min();
let hi = outer.max();
let a = inner.min();
let b = inner.max();
lo.x < a.x && lo.y < a.y && lo.z < a.z && hi.x > b.x && hi.y > b.y && hi.z > b.z
}
/// A certified axis-aligned face point, shared by propagation and Beer intervals.
/// This is used only by the explicit nested-box path, never by general spawn.
#[derive(Clone, Copy)]
pub(super) struct ExactBoxFace {
pub point: Vec3,
axis: usize,
outward: f64,
entering: bool,
}
impl ExactBoxFace {
pub fn spawn(self, direction: Vec3, reflected: bool) -> Result<super::Ray> {
let ray = super::Ray::new(self.point, direction, 0.0_f64.next_up(), super::MAX_DISTANCE_MM)?;
let side = super::component(ray.direction(), self.axis) * self.outward;
let exterior = if reflected { self.entering } else { !self.entering };
if (exterior && side <= 0.0) || (!exterior && side >= 0.0) {
return Err(TraceError::InvalidInput("nested box continuation is tangent or on the wrong side"));
}
Ok(ray)
}
}
/// Recover the exact constant-axis coordinate from the actual certified face.
/// Tangential coordinates are validated, never clamped or displaced. Equal
/// ray/other-face depths conservatively refuse independently of ordinal ties.
pub(super) fn exact_box_face(bounds: Bounds, vertices: [Vec3; 3], normal: Vec3, position: Vec3, incoming: super::Ray) -> Result<ExactBoxFace> {
let mut face = None;
for axis in 0..3 {
let [a, b, c] = vertices.map(|v| super::component(v, axis));
if a == b && b == c {
if face.is_some() {
return Err(TraceError::InvalidInput("nested box face has ambiguous constant axes"));
}
face = Some((axis, a));
}
}
let (axis, plane) = face.ok_or(TraceError::InvalidInput("nested box face is not axis aligned"))?;
let lo = bounds.min();
let hi = bounds.max();
let outward = if plane == super::component(lo, axis) {
-1.0
} else if plane == super::component(hi, axis) {
1.0
} else {
return Err(TraceError::InvalidInput("nested box face disagrees with its certificate"));
};
let axis_vector = match axis {
0 => Vec3::X,
1 => Vec3::Y,
2 => Vec3::Z,
_ => return Err(TraceError::Internal("nested box face axis")),
};
if normal != axis_vector * outward || Triangle::new(vertices)?.geometric_normal() != Some(normal) {
return Err(TraceError::InvalidInput("nested box outward normal disagrees with its certified face"));
}
for vertex in vertices {
for a in 0..3 {
let coordinate = super::component(vertex, a);
if coordinate != super::component(lo, a) && coordinate != super::component(hi, a) {
return Err(TraceError::InvalidInput("nested box face vertex is not a certified corner"));
}
}
}
let point = match axis {
0 => Vec3::new(plane, position.y, position.z),
1 => Vec3::new(position.x, plane, position.z),
2 => Vec3::new(position.x, position.y, plane),
_ => return Err(TraceError::Internal("nested box point axis")),
};
if !point.is_finite() {
return Err(TraceError::InvalidInput("nested box reconstructed point is not finite"));
}
let direction = super::component(incoming.direction(), axis);
if direction == 0.0 {
return Err(TraceError::InvalidInput("nested box incident ray is tangent"));
}
let depth = (plane - super::component(incoming.origin(), axis)) / direction;
let [min, max] = incoming.interval();
if !depth.is_finite() || depth < min || depth > max {
return Err(TraceError::InvalidInput("nested box face is outside the incident ray interval"));
}
for a in 0..3 {
if a == axis {
continue;
}
let coordinate = super::component(point, a);
if coordinate <= super::component(lo, a) || coordinate >= super::component(hi, a) {
return Err(TraceError::InvalidInput("nested box edge, corner or outside-face point is ambiguous"));
}
let d = super::component(incoming.direction(), a);
if d != 0.0 {
for plane in [super::component(lo, a), super::component(hi, a)] {
let other = (plane - super::component(incoming.origin(), a)) / d;
if other == depth {
return Err(TraceError::InvalidInput("nested box simultaneous face depths are ambiguous"));
}
}
}
}
Ok(ExactBoxFace { point, axis, outward, entering: direction * outward < 0.0 })
}
/// Hard crossing ceiling for eight disjoint certified boxes (two faces each).
pub const MAX_DIRECT_CROSSINGS: u8 = (MAX_OVERRIDES * 2) as u8;
#[derive(Clone, Copy)]
struct DirectHit {
body: usize,
vertices: [Vec3; 3],
geometry: super::TriangleHit,
}
/// Incremental RGB visibility along ONE straight exterior-to-exterior connection.
/// This is a direct-light approximation, not refracted transport or caustics.
/// Selected optics are revalidated and certified against the borrowed capture.
/// Boxes must be disjoint; sheets touching/inside boxes and endpoints on/inside
/// boxes refuse. Unselected geometry blocks. The interval is [min,max): geometry
/// exactly at a finite light endpoint does not block that light.
///
/// No BVH tie API exists for this consumer yet. A pass scans the captured source,
/// one triangle (or body transition) per step. Equal-depth different bodies refuse
/// rather than silently skipping an opaque blocker behind a selected boundary.
/// Cost is O((crossings+1)*(triangles+bodies)), not accelerated traversal. Every
/// step debits the caller's cumulative work and checks cancellation. Memory is
/// fixed, with borrowed scene/options. Valid setup/steps create no containers;
/// invalid options may construct the existing validator's bounded error String.
/// The future prepared-scene consumer passes its existing `PreparedScene.source`.
pub struct DirectVisibility<'a> {
source: &'a ProductScene,
options: &'a crate::job::RaytraceOptions,
certificates: [Option<Certificate>; MAX_OVERRIDES],
ray: super::Ray,
body: usize,
triangle: usize,
scanned: usize,
nearest: Option<DirectHit>,
ambiguous: bool,
optical: bool,
medium: Option<(usize, Vec3)>,
sheets: u8,
transmittance: Vec3,
crossings: u8,
crossing_limit: u8,
terminal: bool,
}
impl<'a> DirectVisibility<'a> {
/// Bounded setup: at most eight small certificates and 64 overlap checks.
/// Setup also spends the SAME caller-owned budget; no fresh work credit.
pub fn new(
source: &'a ProductScene,
options: &'a crate::job::RaytraceOptions,
ray: super::Ray,
crossing_limit: u8,
work: &mut super::WorkBudget,
cancel: &crate::job::CancelToken,
) -> Result<Self> {
work.tick(cancel)?;
if !(1..=MAX_DIRECT_CROSSINGS).contains(&crossing_limit) || source.meshes.len() > crate::job::MAX_CPU_BODIES {
return Err(TraceError::InvalidInput("direct visibility crossing or body bound"));
}
options.validate().map_err(|_| TraceError::InvalidInput("invalid direct visibility options"))?;
let mut certificates = [None; MAX_OVERRIDES];
let start = ray.at(ray.interval()[0])?;
let end = ray.at(ray.interval()[1])?;
for (slot, input) in certificates.iter_mut().zip(&options.dielectrics) {
work.tick(cancel)?;
let certificate = certify(source, input)?;
if let Certificate::ClosedBox(bounds) = certificate {
if contains(bounds, ray.origin()) || contains(bounds, start) || contains(bounds, end) {
return Err(TraceError::InvalidInput("direct visibility endpoints must be outside certified boxes"));
}
}
*slot = Some(certificate);
}
for (i, a) in certificates.iter().take(options.dielectrics.len()).enumerate() {
for (j, b) in certificates.iter().take(options.dielectrics.len()).enumerate().skip(i + 1) {
work.tick(cancel)?;
let (Some(a), Some(b)) = (a, b) else { continue };
let bounds = |slot: usize, certificate: Certificate| -> Result<Bounds> {
match certificate {
Certificate::ClosedBox(bounds) => Ok(bounds),
Certificate::ThinSheet => {
let input = options.dielectrics.get(slot).ok_or(TraceError::Internal("direct certificate slot"))?;
let body = source.meshes.get(input.body_index).ok_or(TraceError::Internal("direct certificate body"))?;
let bounds = body.mesh.bounds();
Bounds::new(bounds.min, bounds.max)
}
}
};
if (matches!(a, Certificate::ClosedBox(_)) || matches!(b, Certificate::ClosedBox(_)))
&& overlaps(bounds(i, *a)?, bounds(j, *b)?)
{
return Err(TraceError::InvalidInput("direct visibility boxes must be disjoint from other selected boundaries"));
}
}
}
super::check_cancel(cancel)?;
Ok(Self {
source, options, certificates, ray, body: 0, triangle: 0, scanned: 0,
nearest: None, ambiguous: false, optical: false, medium: None, sheets: 0,
transmittance: Vec3::new(1.0, 1.0, 1.0), crossings: 0, crossing_limit, terminal: false,
})
}
/// None means real work remains. Only Some(rgb) is a completed connection.
/// Work/crossing exhaustion, cancellation and ambiguity are errors, never black
/// or white fallback values. Success OR error makes this cursor terminal.
pub fn step(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
if self.terminal {
return Err(TraceError::InvalidInput("direct visibility cursor is terminal"));
}
let result = self.advance(work, cancel);
if !matches!(result, Ok(None)) {
self.terminal = true;
}
result
}
fn advance(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
work.tick(cancel)?;
if !self.optical {
if let Some(body) = self.source.meshes.get(self.body) {
// A certified sheet is ONE plane. Its two triangles can round
// their depths differently at a shared edge; it must never be
// attenuated twice. Skip that entire immutable certified body
// after its first crossing, not a numerical distance interval.
if self.options.dielectrics.iter().enumerate().any(|(slot, input)| input.body_index == self.body && self.sheets & (1u8 << slot) != 0) {
self.body += 1;
self.triangle = 0;
return Ok(None);
}
if let Some(indices) = body.mesh.triangles.get(self.triangle) {
self.scanned = self.scanned.checked_add(1).filter(|n| *n <= crate::job::MAX_CPU_TRIANGLES)
.ok_or(TraceError::WorkLimit)?;
let mut vertices = [Vec3::ZERO; 3];
for (point, index) in vertices.iter_mut().zip(indices) {
*point = *body.mesh.positions.get(*index as usize).ok_or(TraceError::InvalidInput("direct visibility vertex index"))?;
}
self.triangle += 1;
if let Some(geometry) = Triangle::new(vertices)?.intersect(&self.ray) {
if geometry.distance < self.ray.interval()[1] {
let hit = DirectHit { body: self.body, vertices, geometry };
match self.nearest {
None => self.nearest = Some(hit),
Some(old) if geometry.distance < old.geometry.distance => {
self.nearest = Some(hit);
self.ambiguous = false;
}
Some(old) if geometry.distance == old.geometry.distance && old.body != self.body => self.ambiguous = true,
_ => {}
}
}
}
} else {
self.body += 1;
self.triangle = 0;
}
return Ok(None);
}
self.optical = true;
return Ok(None);
}
if self.ambiguous {
return Err(TraceError::InvalidInput("coincident direct visibility bodies are ambiguous"));
}
let Some(hit) = self.nearest else {
return if self.medium.is_some() {
Err(TraceError::InvalidInput("direct visibility missed its certified box exit"))
} else {
Ok(Some(self.transmittance))
};
};
let Some((slot, input)) = self.options.dielectrics.iter().enumerate().find(|(_, input)| input.body_index == hit.body) else {
return Ok(Some(Vec3::ZERO));
};
if self.crossings == self.crossing_limit {
return Err(TraceError::WorkLimit);
}
let outward = hit.geometry.geometric_normal;
let entering = outward.dot(self.ray.direction()) < 0.0;
let normal = if entering { outward } else { -outward };
match self.certificates.get(slot).copied().flatten().ok_or(TraceError::Internal("direct visibility certificate missing"))? {
Certificate::ThinSheet => {
if self.medium.is_some() {
return Err(TraceError::InvalidInput("direct visibility sheet inside a box"));
}
let (_, _, transmission) = thin_sheet(self.ray.direction(), normal, input)?;
self.transmittance = multiply(self.transmittance, transmission);
self.sheets |= 1u8 << slot;
}
Certificate::ClosedBox(bounds) => {
let [a, b, c] = hit.vertices;
let [wa, wb, wc] = hit.geometry.barycentric;
let face = exact_box_face(bounds, hit.vertices, outward, a * wa + b * wb + c * wc, self.ray)?;
// External-angle Fresnel on BOTH straight connections. The ray is
// not bent, so an internal critical angle here would invent TIR.
let transmit = 1.0 - interface(self.ray.direction(), normal, 1.0, input.ior)?.reflectance;
self.transmittance = self.transmittance * transmit;
if entering {
if self.medium.is_some() {
return Err(TraceError::InvalidInput("direct visibility entered overlapping boxes"));
}
self.medium = Some((slot, face.point));
} else {
let (active, start) = self.medium.ok_or(TraceError::InvalidInput("direct visibility exited without entry"))?;
if active != slot {
return Err(TraceError::InvalidInput("direct visibility box exit identity mismatch"));
}
let delta = face.point - start;
let scale = delta.x.abs().max(delta.y.abs()).max(delta.z.abs());
if scale == 0.0 || !scale.is_finite() {
return Err(TraceError::InvalidInput("direct visibility box segment is degenerate"));
}
let distance = scale * (delta / scale).len2().sqrt();
self.transmittance = multiply(self.transmittance, attenuation(input.absorption_per_mm, distance)?);
self.medium = None;
}
}
}
if !self.transmittance.is_finite() || [self.transmittance.x, self.transmittance.y, self.transmittance.z].into_iter().any(|v| !(0.0..=1.0).contains(&v)) {
return Err(TraceError::InvalidInput("invalid direct visibility transmittance"));
}
self.crossings += 1;
if self.transmittance == Vec3::ZERO {
return Ok(Some(Vec3::ZERO));
}
// Preserve the original origin AND normalized direction. Calling Ray::new
// again could renormalize it and change the tie/interval calculations.
self.ray.min = hit.geometry.distance.next_up();
if self.ray.min >= self.ray.max {
return if self.medium.is_some() {
Err(TraceError::InvalidInput("direct visibility interval cannot resolve the exit"))
} else {
Ok(Some(self.transmittance))
};
}
self.body = 0;
self.triangle = 0;
self.scanned = 0;
self.nearest = None;
self.ambiguous = false;
self.optical = false;
Ok(None)
}
}
#[cfg(test)]
#[path = "dielectric_tests.rs"]
pub(super) mod tests;
@@ -0,0 +1,986 @@
use super::*;
use crate::{
ProductMesh,
job::{CancelToken, DielectricBoundary, ImageRequest, Integrator, RaytraceOptions, TaskPoll},
};
use solidcraft_geom::Mesh;
use std::sync::Arc;
pub(in crate::raytrace) fn box_body(thickness: f64) -> ProductMesh {
let positions = (0..8)
.map(|i| Vec3::new(if i & 1 == 0 { -10.0 } else { 10.0 }, if i & 2 == 0 { -10.0 } else { 10.0 }, if i & 4 == 0 { 0.0 } else { thickness }))
.collect();
let triangles =
vec![[0, 2, 3], [0, 3, 1], [4, 5, 7], [4, 7, 6], [0, 4, 6], [0, 6, 2], [1, 3, 7], [1, 7, 5], [0, 1, 5], [0, 5, 4], [2, 6, 7], [2, 7, 3]];
ProductMesh {
transmission: None,
name: "captured slab@occurrence-2".into(),
mesh: Arc::new(Mesh { positions, triangles, ..Default::default() }),
color: [255; 3],
opacity: 1.0,
surface: None,
faces: Vec::new(),
}
}
pub(in crate::raytrace) fn scene(body: ProductMesh) -> ProductScene {
let bounds = body.mesh.bounds();
ProductScene { meshes: vec![body], bounds }
}
pub(in crate::raytrace) fn input(boundary: DielectricBoundary) -> DielectricOptions {
DielectricOptions {
body_index: 0,
body_name: "captured slab@occurrence-2".into(),
boundary,
ior: 1.5,
absorption_per_mm: [0.0; 3],
thickness_mm: 0.0,
}
}
fn environment() -> Option<crate::job::RaytraceEnvironmentOptions> {
Some(crate::job::RaytraceEnvironmentOptions { color: [1.0; 3], intensity: 1.0 })
}
pub(in crate::raytrace) fn request(options: RaytraceOptions) -> ImageRequest {
let mut camera = crate::Camera { distance: 50.0, ..Default::default() };
camera.set_view(crate::StandardView::Top);
ImageRequest {
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
integrator: Integrator::Raytrace,
raytrace: Some(options),
camera,
width: 1,
height: 1,
background: [255; 3],
transparent_background: false,
}
}
fn render(scene: ProductScene, options: RaytraceOptions) -> [u8; 4] {
let request = request(options.clone());
let mut task = super::super::image::ImageTask::new(scene, request, CancelToken::default(), options).unwrap();
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
return image.get_pixel(0, 0).0;
}
}
panic!("bounded dielectric fixture failed to finish");
}
#[test]
fn normal_interface_has_four_percent_reflection() {
let hit = interface(-Vec3::Z, Vec3::Z, 1.0, 1.5).unwrap();
assert!((hit.reflectance - 0.04).abs() < 1e-14);
assert_eq!(hit.transmitted, Some(-Vec3::Z));
assert_eq!(hit.reflected, Vec3::Z);
}
#[test]
fn thirty_degree_snell_and_parallel_exit_are_reversible() {
let incident = Vec3::new(0.5, 0.0, -(3.0f64).sqrt() / 2.0);
let enter = interface(incident, Vec3::Z, 1.0, 1.5).unwrap();
let inside = enter.transmitted.unwrap();
assert!((inside.x - 1.0 / 3.0).abs() < 1e-14);
let exit = interface(inside, Vec3::Z, 1.5, 1.0).unwrap();
assert!((exit.transmitted.unwrap() - incident).len() < 1e-14);
assert!((exit.reflectance - enter.reflectance).abs() < 1e-14);
}
#[test]
fn internal_grazing_ray_is_totally_reflected() {
let hit = interface(Vec3::new(0.8, 0.0, -0.6), Vec3::Z, 1.5, 1.0).unwrap();
assert_eq!(hit.transmitted, None);
assert_eq!(hit.reflectance, 1.0);
}
#[test]
fn beer_lambert_uses_actual_millimetre_path() {
let a = attenuation([0.1, 0.2, 0.0], 2.0).unwrap();
let b = attenuation([0.1, 0.2, 0.0], 8.0).unwrap();
assert!((b.x - a.x.powi(4)).abs() < 1e-14);
assert!((b.y - a.y.powi(4)).abs() < 1e-14);
assert_eq!(b.z, 1.0);
}
#[test]
fn hostile_optics_are_refused() {
for v in [f64::NAN, f64::INFINITY, -1.0] {
assert!(interface(-Vec3::Z, Vec3::Z, v, 1.5).is_err());
assert!(attenuation([v, 0.0, 0.0], 1.0).is_err());
assert!(attenuation([0.0; 3], v).is_err());
}
assert!(interface(Vec3::Z, Vec3::Z, 1.0, 1.5).is_err());
assert_eq!(interface(Vec3::X, Vec3::Z, 1.0, 1.0).unwrap().reflectance, 0.0);
}
#[test]
fn closed_box_certification_requires_complete_outward_boundary() {
let original = box_body(2.0);
assert!(matches!(certify(&scene(original.clone()), &input(DielectricBoundary::ClosedBox)), Ok(Certificate::ClosedBox(_))));
let mut body = original.clone();
Arc::make_mut(&mut body.mesh).triangles.pop();
assert!(certify(&scene(body), &input(DielectricBoundary::ClosedBox)).is_err());
let mut body = original.clone();
Arc::make_mut(&mut body.mesh).triangles[0].swap(0, 1);
assert!(certify(&scene(body), &input(DielectricBoundary::ClosedBox)).is_err());
let mut body = original;
let mesh = Arc::make_mut(&mut body.mesh);
mesh.triangles[1] = mesh.triangles[0];
assert!(certify(&scene(body), &input(DielectricBoundary::ClosedBox)).is_err());
}
#[test]
fn captured_occurrence_identity_and_unsupported_boundary_are_refused() {
let scene = scene(box_body(2.0));
let mut material = input(DielectricBoundary::ClosedBox);
material.body_name = "captured slab@occurrence-1".into();
assert!(certify(&scene, &material).is_err());
material = input(DielectricBoundary::ThinSheet);
assert!(certify(&scene, &material).is_err());
}
#[test]
fn actual_png_closed_volume_absorption_uses_two_and_eight_mm() {
for thickness in [2.0, 8.0] {
let mut material = input(DielectricBoundary::ClosedBox);
material.ior = 1.0;
material.absorption_per_mm = [0.1, 0.0, 0.0];
let options = RaytraceOptions {
dielectrics: vec![material],
environment: environment(),
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
let actual = render(scene(box_body(thickness)), options);
let linear = Vec3::new((-0.1 * thickness).exp(), 1.0, 1.0);
let mapped = super::super::lighting::map_to_display(linear, 0.0).unwrap();
let encode = |v: f64| ((if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }) * 255.0).round() as u8;
assert_eq!(actual, [encode(mapped.x), encode(mapped.y), encode(mapped.z), 255]);
}
}
#[test]
fn actual_png_clear_volume_includes_multiple_fresnel_branches() {
let options = RaytraceOptions {
dielectrics: vec![input(DielectricBoundary::ClosedBox)],
environment: environment(),
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
let actual = render(scene(box_body(2.0)), options);
// Constant unit environment, no absorption: all branches sum to almost one.
// Eight boundaries drop only a very small unresolved Fresnel tail.
assert_eq!(actual, [188, 188, 188, 255]);
}
#[test]
fn overlapping_volumes_are_refused_before_pixel_work() {
let mut scene = scene(box_body(2.0));
scene.meshes.push(box_body(2.0));
let first = input(DielectricBoundary::ClosedBox);
let mut second = first.clone();
second.body_index = 1;
let options = RaytraceOptions { dielectrics: vec![first, second], ..Default::default() };
assert!(super::super::image::admit(&scene, &request(options.clone()), &options).is_err());
}
#[test]
fn cancelled_dielectric_job_cannot_publish_png() {
let options = RaytraceOptions { dielectrics: vec![input(DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut task = super::super::image::ImageTask::new(scene(box_body(2.0)), request(options.clone()), cancel.clone(), options).unwrap();
task.step(1).unwrap();
cancel.cancel();
assert_eq!(task.step(1).err().unwrap().code, crate::job::ErrorCode::Cancelled);
}
#[test]
fn runtime_dielectric_inputs_are_bounded_and_default_off() {
assert!(RaytraceOptions::default().dielectrics.is_empty());
let options = RaytraceOptions { dielectrics: vec![input(DielectricBoundary::ClosedBox); 9], ..Default::default() };
assert!(options.validate().is_err());
let mut material = input(DielectricBoundary::ClosedBox);
material.thickness_mm = 1.0;
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
assert!(options.validate().is_err());
let oversized = serde_json::json!({"dielectrics": vec![input(DielectricBoundary::ThinSheet);9]});
assert!(serde_json::from_value::<RaytraceOptions>(oversized).is_err());
assert!(serde_json::from_value::<RaytraceOptions>(serde_json::json!({"dielectrics":[{"body_index":0,"body_name":"x".repeat(1025),"boundary":"closed_box","ior":1.5,"absorption_per_mm":[0,0,0],"thickness_mm":0}]})).is_err());
}
#[test]
fn oblique_parallel_slab_offset_scales_with_actual_thickness() {
let direction = Vec3::new(0.5, 0.0, -(3.0f64).sqrt() / 2.0);
let inside = interface(direction, Vec3::Z, 1.0, 1.5).unwrap().transmitted.unwrap();
let mut offsets = Vec::new();
for thickness in [2.0, 8.0] {
let bottom = Triangle::new([Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 0.0), Vec3::new(10.0, -10.0, 0.0)]).unwrap();
let ray = super::super::Ray::new(Vec3::new(0.0, 0.0, thickness), inside, 0.0, 100.0).unwrap();
let hit = bottom.intersect(&ray).unwrap();
let exit = ray.at(hit.distance).unwrap();
assert!((hit.distance - thickness / -inside.z).abs() < 1e-13);
let undeviated_x = thickness * direction.x / -direction.z;
offsets.push(undeviated_x - exit.x);
}
assert!((offsets[1] - 4.0 * offsets[0]).abs() < 1e-13);
}
#[test]
fn thin_sheet_is_certified_separately_and_duplicate_faces_refuse() {
let mut body = box_body(2.0);
Arc::make_mut(&mut body.mesh).triangles = vec![[4, 5, 7], [4, 7, 6]];
let mut material = input(DielectricBoundary::ThinSheet);
material.ior = 1.0;
material.thickness_mm = 8.0;
material.absorption_per_mm = [0.1, 0.0, 0.0];
assert!(matches!(certify(&scene(body.clone()), &material), Ok(Certificate::ThinSheet)));
let options =
RaytraceOptions { dielectrics: vec![material], environment: environment(), lights: Some(Vec::new()), shadows: false, ..Default::default() };
let actual = render(scene(body.clone()), options);
let closed = RaytraceOptions {
environment: environment(),
dielectrics: vec![DielectricOptions { ior: 1.0, absorption_per_mm: [0.1, 0.0, 0.0], ..input(DielectricBoundary::ClosedBox) }],
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
assert_eq!(actual, render(scene(box_body(8.0)), closed));
let mesh = Arc::make_mut(&mut body.mesh);
mesh.triangles[1] = mesh.triangles[0];
assert!(certify(&scene(body), &input(DielectricBoundary::ThinSheet)).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_dielectric_job_uses_existing_host_and_retained_png_lease() {
use crate::job::{CpuHost, JobId, Phase};
let host = CpuHost::for_platform().unwrap();
let options = RaytraceOptions {
dielectrics: vec![input(DielectricBoundary::ClosedBox)],
environment: environment(),
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
let scene = scene(box_body(2.0));
let weak = Arc::downgrade(&scene.meshes[0].mesh);
let id = JobId { session: 1655, serial: 1, generation: 1 };
let handle = host.submit(id, 1, scene, request(options)).unwrap();
assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
let frame = handle.frame().unwrap().unwrap();
assert!(::image::load_from_memory(frame.png()).is_ok());
assert!(weak.upgrade().is_none());
assert!(host.admitted_bytes() > 0);
drop(handle);
assert!(host.admitted_bytes() > 0);
drop(frame);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while host.admitted_bytes() != 0 {
assert!(std::time::Instant::now() < deadline, "worker did not release terminal request record");
std::thread::yield_now();
}
}
#[test]
fn secondary_environment_is_explicit_and_primary_background_remains_display_srgb() {
let options =
RaytraceOptions { dielectrics: vec![input(DielectricBoundary::ClosedBox)], lights: Some(Vec::new()), shadows: false, ..Default::default() };
assert_eq!(render(scene(box_body(2.0)), options.clone()), [0, 0, 0, 255]);
let mut request = request(options.clone());
request.camera.target.x = 50.0;
request.background = [20, 30, 40];
request.raytrace.as_mut().unwrap().exposure_ev = 4.0;
let options = request.raytrace.clone().unwrap();
let mut task = super::super::image::ImageTask::new(scene(box_body(2.0)), request, CancelToken::default(), options).unwrap();
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
assert_eq!(::image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0, [20, 30, 40, 255]);
return;
}
}
panic!("primary background fixture failed to finish");
}
#[test]
fn typed_runtime_roundtrip_preserves_default_empty_and_explicit_lists() {
for options in [
RaytraceOptions::default(),
RaytraceOptions { lights: Some(Vec::new()), ..Default::default() },
RaytraceOptions { dielectrics: vec![input(DielectricBoundary::ClosedBox)], environment: environment(), ..Default::default() },
] {
let encoded = serde_json::to_value(&options).unwrap();
let decoded: RaytraceOptions = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, options);
assert!(decoded.validate().is_ok());
}
}
#[test]
fn actual_oblique_png_selects_the_snell_shifted_colour_target() {
fn target(name: &str, y0: f64, y1: f64, color: Vec3) -> ProductMesh {
ProductMesh {
transmission: None,
name: name.into(),
mesh: Arc::new(Mesh {
positions: vec![Vec3::new(-20.0, y0, -1.0), Vec3::new(20.0, y0, -1.0), Vec3::new(20.0, y1, -1.0), Vec3::new(-20.0, y1, -1.0)],
normals: vec![Vec3::Z; 4],
triangles: vec![[0, 1, 2], [0, 2, 3]],
..Default::default()
}),
color: [255; 3],
opacity: 1.0,
surface: Some(crate::surface::OpaqueSurface::new(color, 0.0, 1.0, 1.0).unwrap()),
faces: Vec::new(),
}
}
let mut scene = scene(box_body(8.0));
scene.meshes.push(target("near blue stripe", -20.0, 4.0, Vec3::Z));
scene.meshes.push(target("far red stripe", 4.0, 20.0, Vec3::X));
for body in &scene.meshes {
scene.bounds = scene.bounds.union(&body.mesh.bounds());
}
// Entry at (0,0,8), incidence30deg. Independent Snell values give
// y=8/sqrt(8)+1/sqrt(3)=3.4058 for glass and 9/sqrt(3)=5.1962
// for index-matched air at target z=-1, on opposite sides of y=4.
let shifted_y = 8.0 / 8.0f64.sqrt() + 1.0 / 3.0f64.sqrt();
let straight_y = 9.0 / 3.0f64.sqrt();
assert!(shifted_y < 4.0 && straight_y > 4.0);
let mut results = Vec::new();
for ior in [1.5, 1.0] {
let options = RaytraceOptions {
dielectrics: vec![DielectricOptions { ior, ..input(DielectricBoundary::ClosedBox) }],
lights: Some(vec![crate::job::DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 4.0 }]),
shadows: false,
..Default::default()
};
let mut request = request(options.clone());
request.camera.yaw = 0.0;
request.camera.pitch = std::f64::consts::PI / 3.0;
request.camera.target = Vec3::new(0.0, 0.0, 8.0);
let mut task = super::super::image::ImageTask::new(scene.clone(), request, CancelToken::default(), options).unwrap();
let mut pixel = None;
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
pixel = Some(::image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0);
break;
}
}
results.push(pixel.expect("oblique slab fixture must finish"));
}
let glass = results[0];
let matched = results[1];
assert!(u16::from(glass[2]) > u16::from(glass[0]) + 50, "glass ray missed the predicted blue stripe: {glass:?}");
assert!(u16::from(matched[0]) > u16::from(matched[2]) + 50, "index-matched ray missed the predicted red stripe: {matched:?}");
}
#[test]
fn thin_sheet_brewster_sums_polarizations_before_averaging() {
// tan(theta_B) = 3/2, so single-interface Rs = 25/169 and Rp = 0.
// The lossless slab returns Rs/(1+Rs) after the unpolarized average.
let direction = Vec3::new(3.0, 0.0, -2.0) / 13.0f64.sqrt();
let material = input(DielectricBoundary::ThinSheet);
let (_, polarized) = polarized_interface(direction, Vec3::Z, 1.0, 1.5).unwrap();
assert!((polarized[0] - 25.0 / 169.0).abs() < 1e-14);
assert!(polarized[1].abs() < 1e-28);
let (optics, reflected, transmitted) = thin_sheet(direction, Vec3::Z, &material).unwrap();
let expected_r = 25.0 / 194.0;
let expected_t = 169.0 / 194.0;
assert!((reflected - Vec3::new(expected_r, expected_r, expected_r)).len() < 1e-14);
assert!((transmitted - Vec3::new(expected_t, expected_t, expected_t)).len() < 1e-14);
assert!((optics.transmitted.unwrap() - direction).len() < 1e-14);
// The old average-before-series result differs by this exact rational.
assert!((50.0 / 363.0 - reflected.x - 625.0 / 70422.0).abs() < 1e-14);
}
#[test]
fn thin_sheet_normal_incidence_keeps_the_lossless_slab_limit() {
let material = input(DielectricBoundary::ThinSheet);
let (_, reflected, transmitted) = thin_sheet(-Vec3::Z, Vec3::Z, &material).unwrap();
// Rs = Rp = 1/25: the two-interface lossless slab has R = 1/13.
let r = 1.0 / 13.0;
let t = 12.0 / 13.0;
assert!((reflected - Vec3::new(r, r, r)).len() < 1e-14);
assert!((transmitted - Vec3::new(t, t, t)).len() < 1e-14);
}
#[test]
fn thin_sheet_matched_indices_keep_beer_lambert_transmission() {
let material = DielectricOptions {
ior: 1.0,
thickness_mm: 2.0,
absorption_per_mm: [2.0f64.ln() / 2.0, 4.0f64.ln() / 2.0, 0.0],
..input(DielectricBoundary::ThinSheet)
};
let (optics, reflected, transmitted) = thin_sheet(-Vec3::Z, Vec3::Z, &material).unwrap();
assert_eq!(reflected, Vec3::ZERO);
assert!((transmitted - Vec3::new(0.5, 0.25, 1.0)).len() < 1e-14);
assert_eq!(optics.transmitted, Some(-Vec3::Z));
}
#[test]
fn thin_sheet_zero_thickness_index_matched_grazing_is_transparent() {
// Equal indices remove the interface; zero thickness means exp(-sigma * 0) = 1.
for absorption_per_mm in [[0.0; 3], [100.0, 0.25, 1.0]] {
let material = DielectricOptions { ior: 1.0, absorption_per_mm, ..input(DielectricBoundary::ThinSheet) };
let (optics, reflected, transmitted) = thin_sheet(Vec3::X, Vec3::Z, &material).unwrap();
assert_eq!(optics.reflectance, 0.0);
assert_eq!(optics.transmitted, Some(Vec3::X));
assert_eq!(reflected, Vec3::ZERO);
assert_eq!(transmitted, Vec3::new(1.0, 1.0, 1.0));
}
}
#[test]
fn thin_sheet_positive_thickness_index_matched_grazing_refuses_without_tir() {
let material = DielectricOptions { ior: 1.0, thickness_mm: 1.0, ..input(DielectricBoundary::ThinSheet) };
let optics = interface(Vec3::X, Vec3::Z, 1.0, 1.0).unwrap();
assert_eq!(optics.transmitted, Some(Vec3::X));
assert_eq!(optics.reflectance, 0.0);
assert!(matches!(thin_sheet(Vec3::X, Vec3::Z, &material), Err(TraceError::InvalidInput("grazing sheet thickness is unsupported"))));
}
#[test]
fn thin_sheet_zero_thickness_grazing_still_validates_absorption() {
for sigma in [f64::NAN, f64::INFINITY, -1.0, 101.0] {
let material = DielectricOptions { ior: 1.0, absorption_per_mm: [sigma, 0.0, 0.0], ..input(DielectricBoundary::ThinSheet) };
assert!(matches!(thin_sheet(Vec3::X, Vec3::Z, &material), Err(TraceError::InvalidInput("dielectric absorption or distance"))));
}
}
#[test]
fn thin_sheet_absorbing_brewster_keeps_separate_internal_returns() {
let direction = Vec3::new(3.0, 0.0, -2.0) / 13.0f64.sqrt();
let material = DielectricOptions {
// cos(theta_t) = 3/sqrt(13), hence the one-pass distance is 1 mm.
thickness_mm: 3.0 / 13.0f64.sqrt(),
absorption_per_mm: [2.0f64.ln(), 2.0f64.ln(), 0.0],
..input(DielectricBoundary::ThinSheet)
};
let (_, reflected, transmitted) = thin_sheet(direction, Vec3::Z, &material).unwrap();
// Independent rational series with Rs = 25/169, Rp = 0, a = 1/2.
let r = 6625.0 / 75746.0;
let t = 65521.0 / 151492.0;
assert!((reflected - Vec3::new(r, r, 25.0 / 194.0)).len() < 1e-14);
assert!((transmitted - Vec3::new(t, t, 169.0 / 194.0)).len() < 1e-14);
assert!(reflected.x + transmitted.x < 1.0);
}
#[test]
fn actual_png_brewster_thin_sheet_matches_reflected_energy() {
let mut sheet = box_body(2.0);
Arc::make_mut(&mut sheet.mesh).triangles.retain(|t| *t == [4, 5, 7] || *t == [4, 7, 6]);
let mut scene = scene(sheet);
let absorber = ProductMesh {
transmission: None,
name: "unlit opaque transmission target".into(),
mesh: Arc::new(Mesh {
positions: vec![Vec3::new(-20.0, -20.0, -1.0), Vec3::new(20.0, -20.0, -1.0), Vec3::new(20.0, 20.0, -1.0), Vec3::new(-20.0, 20.0, -1.0)],
normals: vec![Vec3::Z; 4],
triangles: vec![[0, 1, 2], [0, 2, 3]],
..Default::default()
}),
color: [0; 3],
opacity: 1.0,
surface: None,
faces: Vec::new(),
};
scene.bounds = scene.bounds.union(&absorber.mesh.bounds());
scene.meshes.push(absorber);
let options = RaytraceOptions {
dielectrics: vec![input(DielectricBoundary::ThinSheet)],
environment: environment(),
lights: Some(Vec::new()),
shadows: false,
..Default::default()
};
let mut request = request(options.clone());
request.camera.yaw = 0.0;
request.camera.pitch = (2.0f64 / 3.0).atan();
request.camera.target = Vec3::new(0.0, 0.0, 2.0);
let (_, direction) = request.camera.ray(0.5, 0.5, 1.0, 1.0);
assert!((-direction.z - 2.0 / 13.0f64.sqrt()).abs() < 1e-14);
let mut task = super::super::image::ImageTask::new(scene, request, CancelToken::default(), options).unwrap();
let mut pixel = None;
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
pixel = Some(::image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0);
break;
}
}
// Only the reflected branch sees the unit white environment. R = 25/194
// maps through Reinhard to 25/219, then sRGB to 95. The old R = 50/363
// maps to 98, so this also detects a consumer still using the old series.
assert_eq!(pixel.expect("Brewster sheet fixture must finish"), [95, 95, 95, 255]);
}
#[test]
fn strict_box_parent_selection_is_order_independent_and_opt_in() {
let bounds = |lo: f64, hi: f64| Bounds::new(Vec3::new(lo, lo, lo), Vec3::new(hi, hi, hi)).unwrap();
let mut certificates = [None; MAX_OVERRIDES];
certificates[0] = Some(Certificate::ClosedBox(bounds(2.0, 3.0)));
certificates[1] = Some(Certificate::ClosedBox(bounds(0.0, 5.0)));
certificates[2] = Some(Certificate::ClosedBox(bounds(1.0, 4.0)));
assert!(parents(&certificates, false).is_err());
assert_eq!(parents(&certificates, true).unwrap(), [Some(2), None, Some(1), None, None, None, None, None]);
certificates.swap(0, 1);
assert_eq!(parents(&certificates, true).unwrap(), [None, Some(2), Some(0), None, None, None, None, None]);
}
#[test]
fn nesting_opt_in_still_refuses_touching_equal_and_partially_overlapping_boxes() {
let outer = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
for (lo, hi) in [
(Vec3::new(0.0, 1.0, 1.0), Vec3::new(3.0, 3.0, 3.0)),
(Vec3::new(4.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
(Vec3::new(4.0, 4.0, 1.0), Vec3::new(5.0, 5.0, 3.0)),
(Vec3::new(4.0, 4.0, 4.0), Vec3::new(5.0, 5.0, 5.0)),
(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)),
(Vec3::new(3.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
] {
let mut certificates = [None; MAX_OVERRIDES];
certificates[0] = Some(Certificate::ClosedBox(outer));
certificates[1] = Some(Certificate::ClosedBox(Bounds::new(lo, hi).unwrap()));
assert!(parents(&certificates, true).is_err());
}
}
pub(in crate::raytrace) fn nested_fixture(reverse: bool) -> (ProductScene, RaytraceOptions) {
let mut outer = box_body(4.0);
outer.name = "Outer".into();
let mut inner = box_body(2.0);
inner.name = "Inner".into();
for p in &mut Arc::make_mut(&mut inner.mesh).positions {
*p = Vec3::new(p.x * 0.5, p.y * 0.5, p.z + 1.0);
}
let mut scene = scene(outer);
scene.meshes.push(inner);
if reverse {
scene.meshes.reverse();
}
let dielectrics = scene
.meshes
.iter()
.enumerate()
.map(|(body_index, body)| DielectricOptions {
body_index,
body_name: body.name.clone(),
boundary: DielectricBoundary::ClosedBox,
ior: 1.0,
absorption_per_mm: if body.name == "Outer" { [0.1, 0.2, 0.0] } else { [0.3, 0.4, 0.0] },
thickness_mm: 0.0,
})
.collect();
(scene, RaytraceOptions { dielectrics, environment: environment(), lights: Some(Vec::new()), shadows: false, ..Default::default() })
}
#[test]
fn actual_nested_png_uses_parent_only_outside_the_inner_interval() {
for reverse in [false, true] {
let (scene, options) = nested_fixture(reverse);
assert!(super::super::image::admit(&scene, &request(options.clone()), &options).is_err());
let mut task = super::super::image::ImageTask::new_with_environment_and_nested(
scene,
request(options.clone()),
CancelToken::default(),
options,
None,
true,
)
.unwrap();
let mut completed = false;
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
// Outer length2, inner length2; Beer radiance (exp(-.8), exp(-1.2), 1).
// Reinhard then IEC sRGB encoding gives these independently fixed bytes.
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
completed = true;
break;
}
}
assert!(completed, "nested final image did not complete");
}
}
#[test]
fn progressive_optics_keep_the_nested_certificate_opt_in_and_interactive_intent() {
let (scene, mut options) = nested_fixture(false);
options.progressive = Some(Default::default());
options.validate().unwrap();
let request = request(options.clone());
// A DTO succeeding never grants a nesting certificate to an ordinary job.
assert!(super::super::image::admit(&scene, &request, &options).is_err());
assert!(super::super::image::admit_with_nested(&scene, &request, &options, true).is_ok());
let interactive = crate::job::interactive::cpu_request(&request).unwrap();
assert_eq!(interactive.raytrace.as_ref().unwrap().dielectrics, options.dielectrics);
assert!(super::super::image::admit(&scene, &interactive, interactive.raytrace.as_ref().unwrap()).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_host_forwards_only_the_existing_nested_media_exception() {
use crate::job::{CpuHost, ErrorCode, JobId, JobOptions, Phase};
let host = CpuHost::for_platform().unwrap();
let (scene, options) = nested_fixture(false);
let request = request(options);
let nested = JobOptions { nested_media: true, ..Default::default() };
assert!(host.admit(&scene, &request, JobOptions::default()).is_err());
assert!(host.admit(&scene, &request, nested).is_ok());
for bad in [
JobOptions { samples: 2, ..nested },
JobOptions { max_depth: 2, ..nested },
JobOptions { seed: 7, ..nested },
JobOptions { exposure_ev: 1.0, ..nested },
JobOptions { transmission_samples: 2, ..nested },
JobOptions { denoise: true, ..nested },
JobOptions { linear_exr: true, ..nested },
] {
assert_eq!(host.admit(&scene, &request, bad).unwrap_err().code, ErrorCode::InvalidRequest);
}
let mut raster = request.clone();
raster.integrator = Integrator::Raster;
raster.raytrace = None;
assert!(host.admit(&scene, &raster, nested).is_err());
let handle = host.submit_with_options(JobId { session: 6302, serial: 1, generation: 1 }, 1, scene, request, nested).unwrap();
assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
assert_eq!(handle.status().unwrap().options.nested_media, true);
let frame = handle.frame().unwrap().unwrap();
let image = ::image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
}
fn face_fixture(axis: usize, positive: bool) -> (Bounds, [Vec3; 3], Vec3, Vec3) {
let point = |a: f64, u: f64, v: f64| match axis {
0 => Vec3::new(a, u, v),
1 => Vec3::new(v, a, u),
2 => Vec3::new(u, v, a),
_ => panic!("test axis"),
};
let bounds = Bounds::new(Vec3::new(1.0, 1.0, 1.0), Vec3::new(4.0, 4.0, 4.0)).unwrap();
let plane = if positive { 4.0 } else { 1.0 };
let mut vertices = [point(plane, 1.0, 1.0), point(plane, 4.0, 1.0), point(plane, 4.0, 4.0)];
if !positive {
vertices.swap(1, 2);
}
let normal = match axis {
0 => Vec3::X,
1 => Vec3::Y,
2 => Vec3::Z,
_ => panic!("test axis"),
} * if positive { 1.0 } else { -1.0 };
(bounds, vertices, normal, point(plane, 2.5, 2.5))
}
#[test]
fn exact_face_excludes_own_zero_depth_and_retains_one_ulp_neighbour_on_every_axis_and_side() {
use super::super::Ray;
for axis in 0..3 {
for positive in [false, true] {
let (bounds, vertices, normal, position) = face_fixture(axis, positive);
for entering in [false, true] {
let direction = if entering { -normal } else { normal };
let incoming = Ray::new(position - direction, direction, 0.0, 10.0).unwrap();
let face = exact_box_face(bounds, vertices, normal, position, incoming).unwrap();
assert_eq!(face.point, position, "face diagonal is valid interior");
let optics = interface(direction, -direction, if entering { 1.0 } else { 1.5 }, if entering { 1.5 } else { 1.0 }).unwrap();
let reflected = face.spawn(optics.reflected, true).unwrap();
let transmitted = face.spawn(optics.transmitted.unwrap(), false).unwrap();
let triangle = Triangle::new(vertices).unwrap();
assert!(triangle.intersect(&reflected).is_none(), "own reflected face at t0 must be excluded");
assert!(triangle.intersect(&transmitted).is_none(), "own transmitted face at t0 must be excluded");
assert_eq!(reflected.origin(), face.point);
assert_eq!(transmitted.origin(), face.point);
assert_eq!(transmitted.interval()[0], 0.0_f64.next_up());
let plane = super::super::component(position, axis);
let next = if super::super::component(direction, axis) > 0.0 { plane.next_up() } else { plane.next_down() };
let neighbour = vertices.map(|v| match axis {
0 => Vec3::new(next, v.y, v.z),
1 => Vec3::new(v.x, next, v.z),
2 => Vec3::new(v.x, v.y, next),
_ => panic!("test axis"),
});
let hit = Triangle::new(neighbour).unwrap().intersect(&transmitted).expect("one-ULP positive face must remain ahead");
assert_eq!(hit.distance, (next - plane).abs());
}
}
}
}
#[test]
fn exact_face_recovers_certified_plane_without_clamping_or_blessing_ties() {
use super::super::Ray;
let (bounds, vertices, normal, point) = face_fixture(2, false);
let incoming = Ray::new(point - Vec3::Z, Vec3::Z, 0.0, 10.0).unwrap();
let rounded = Vec3::new(point.x, point.y, point.z.next_up());
let face = exact_box_face(bounds, vertices, normal, rounded, incoming).unwrap();
assert_eq!(face.point, point);
for bad in [Vec3::new(1.0, 2.5, 1.0), Vec3::new(1.0, 1.0, 1.0), Vec3::new(1.0_f64.next_down(), 2.5, 1.0), Vec3::new(f64::NAN, 2.5, 1.0)] {
assert!(exact_box_face(bounds, vertices, normal, bad, incoming).is_err());
}
let tied = Ray::new(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0), 0.0, 10.0).unwrap();
// Even if rounded tangential reconstruction claims interior, independently
// equal actual ray/face depths cannot inherit the BVH ordinal's blessing.
let uncertain = Vec3::new(1.0_f64.next_up(), 1.0_f64.next_up(), 1.0);
assert!(exact_box_face(bounds, vertices, normal, uncertain, tied).is_err());
assert!(exact_box_face(bounds, vertices, -normal, point, incoming).is_err());
let other_bounds = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
assert!(exact_box_face(other_bounds, vertices, normal, point, incoming).is_err());
let tangent = Ray::new(point - Vec3::X, Vec3::X, 0.0, 10.0).unwrap();
assert!(exact_box_face(bounds, vertices, normal, point, tangent).is_err());
assert!(face.spawn(Vec3::X, true).is_err());
assert!(face.spawn(-Vec3::Z, false).is_err());
assert!(face.spawn(Vec3::Z, true).is_err());
}
fn direct_capture(bodies: Vec<ProductMesh>) -> ProductScene {
let mut bounds = bodies[0].mesh.bounds();
for body in &bodies[1..] {
let next = body.mesh.bounds();
bounds.min = bounds.min.min(next.min);
bounds.max = bounds.max.max(next.max);
}
ProductScene { meshes: bodies, bounds }
}
fn direct_box(name: &str, offset: f64) -> ProductMesh {
let mut body = box_body(1.0);
body.name = name.into();
for p in &mut Arc::make_mut(&mut body.mesh).positions { p.z += offset; }
body
}
fn direct_sheet(name: &str, offset: f64) -> ProductMesh {
let mut body = direct_box(name, offset);
let mesh = Arc::make_mut(&mut body.mesh);
mesh.triangles.truncate(2);
mesh.positions.truncate(4);
body
}
fn direct_material(body: usize, name: &str, boundary: DielectricBoundary) -> DielectricOptions {
DielectricOptions { body_index: body, body_name: name.into(), boundary, ior: 1.0,
absorption_per_mm: [2.0_f64.ln(), 4.0_f64.ln(), 0.0],
thickness_mm: if boundary == DielectricBoundary::ThinSheet { 1.0 } else { 0.0 } }
}
fn direct_ray(max: f64) -> super::super::Ray {
super::super::Ray::new(Vec3::new(0.25, 0.125, -2.0), Vec3::Z, 0.0, max).unwrap()
}
fn direct_complete(source: &ProductScene, options: &RaytraceOptions, ray: super::super::Ray) -> Vec3 {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut visibility = DirectVisibility::new(source, options, ray, MAX_DIRECT_CROSSINGS, &mut work, &cancel).unwrap();
for _ in 0..10_000 {
if let Some(rgb) = visibility.step(&mut work, &cancel).unwrap() {
assert!(visibility.step(&mut work, &cancel).is_err());
return rgb;
}
}
panic!("bounded direct connection did not complete");
}
fn direct_rgb(actual: Vec3, expected: Vec3) {
for (a, b) in [actual.x, actual.y, actual.z].into_iter().zip([expected.x, expected.y, expected.z]) {
assert!((a - b).abs() <= 1e-13, "actual={actual:?}, expected={expected:?}");
}
}
#[test]
fn direct_box_beer_uses_original_endpoints_and_multiplies_disjoint_rgb() {
let source = direct_capture(vec![direct_box("one", 0.0), direct_box("two", 3.0)]);
let first = direct_material(0, "one", DielectricBoundary::ClosedBox);
let second = direct_material(1, "two", DielectricBoundary::ClosedBox);
let options = RaytraceOptions { dielectrics: vec![first, second], ..Default::default() };
// sigma=(ln2,ln4,0), two one-mm slabs: exp(-2*sigma)=(1/4,1/16,1).
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), Vec3::new(0.25, 0.0625, 1.0));
let one = direct_capture(vec![direct_box("one", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "one", DielectricBoundary::ClosedBox)], ..Default::default() };
direct_rgb(direct_complete(&one, &options, direct_ray(10.0)), Vec3::new(0.5, 0.25, 1.0));
}
#[test]
fn direct_sheet_shared_diagonal_is_one_crossing_and_uses_virtual_beer_distance() {
let source = direct_capture(vec![direct_sheet("sheet", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "sheet", DielectricBoundary::ThinSheet)], ..Default::default() };
let ray = super::super::Ray::new(Vec3::new(0.25, 0.25, -2.0), Vec3::Z, 0.0, 10.0).unwrap();
direct_rgb(direct_complete(&source, &options, ray), Vec3::new(0.5, 0.25, 1.0));
}
#[test]
fn direct_exterior_box_fresnel_is_not_a_refracted_or_infinite_bounce_slab() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let material = DielectricOptions { ior: 1.5, absorption_per_mm: [0.0; 3], ..direct_material(0, "glass", DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
// Normal external R=1/25; this straight connection has (24/25)^2, with no reflected returns.
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), Vec3::new(576.0/625.0, 576.0/625.0, 576.0/625.0));
let sheet = direct_capture(vec![direct_sheet("sheet", 0.0)]);
let material = DielectricOptions { ior: 1.5, absorption_per_mm: [0.0; 3], ..direct_material(0, "sheet", DielectricBoundary::ThinSheet) };
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
// Existing incoherent sheet closure sums its virtual internal returns: (1-R)/(1+R)=12/13.
direct_rgb(direct_complete(&sheet, &options, direct_ray(10.0)), Vec3::new(12.0/13.0, 12.0/13.0, 12.0/13.0));
}
#[test]
fn direct_connection_keeps_unselected_opaque_blockers_and_half_open_light_endpoint() {
let options = RaytraceOptions::default();
for (z, expected) in [(7.0, Vec3::ZERO), (8.0, Vec3::new(1.0,1.0,1.0)), (9.0, Vec3::new(1.0,1.0,1.0))] {
let source = direct_capture(vec![direct_sheet("opaque", z)]);
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), expected);
}
let source = direct_capture(vec![direct_box("glass", 0.0), direct_sheet("blocker", 5.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
assert_eq!(direct_complete(&source, &options, direct_ray(10.0)), Vec3::ZERO);
}
#[test]
fn direct_connection_refuses_coincident_different_bodies_instead_of_skipping_opaque() {
let source = direct_capture(vec![direct_sheet("selected", 0.0), direct_sheet("opaque", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "selected", DielectricBoundary::ThinSheet)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut cursor = DirectVisibility::new(&source, &options, direct_ray(10.0), MAX_DIRECT_CROSSINGS, &mut work, &cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work, &cancel) {
Ok(None) => {}
Err(super::super::TraceError::InvalidInput(_)) => {
assert!(cursor.step(&mut work, &cancel).is_err());
return;
}
other => panic!("ambiguous visibility must refuse: {other:?}"),
}
}
panic!("coincident boundary did not refuse");
}
#[test]
fn direct_connection_bounds_validate_identity_endpoints_overlap_and_crossing_budget() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
for limit in [0, MAX_DIRECT_CROSSINGS+1] {
assert!(DirectVisibility::new(&source, &options, direct_ray(10.0), limit, &mut work, &cancel).is_err());
}
// End at entry face, inside, and at exit face: all explicitly outside this foundation's domain.
for max in [2.0, 2.5, 3.0] {
assert!(DirectVisibility::new(&source, &options, direct_ray(max), 2, &mut work, &cancel).is_err());
}
let inside = super::super::Ray::new(Vec3::new(0.25, 0.125, 0.5), Vec3::Z, 0.0, 10.0).unwrap();
assert!(DirectVisibility::new(&source, &options, inside, 2, &mut work, &cancel).is_err());
let mut wrong = options.clone();wrong.dielectrics[0].body_name="wrong".into();
assert!(DirectVisibility::new(&source, &wrong, direct_ray(10.0), 2, &mut work, &cancel).is_err());
let overlap = direct_capture(vec![direct_box("a", 0.0), direct_box("b", 0.5)]);
let overlapping = RaytraceOptions { dielectrics: vec![direct_material(0,"a",DielectricBoundary::ClosedBox),direct_material(1,"b",DielectricBoundary::ClosedBox)], ..Default::default() };
assert!(DirectVisibility::new(&overlap,&overlapping,direct_ray(10.0),4,&mut work,&cancel).is_err());
let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),1,&mut work,&cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work,&cancel) {
Ok(None) => {}
Err(super::super::TraceError::WorkLimit) => { assert!(cursor.step(&mut work,&cancel).is_err());return; }
other => panic!("insufficient crossing budget must refuse: {other:?}"),
}
}
panic!("crossing cap did not stop");
}
#[test]
fn direct_connection_spends_caller_work_and_cancellation_is_terminal_without_result() {
let source = direct_capture(vec![direct_box("glass",0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).unwrap();
let used = work.used();
assert!(used>0);
assert_eq!(cursor.step(&mut work,&cancel).unwrap(),None);
assert_eq!(work.used(),used+1);
work.charge(work.remaining(),&cancel).unwrap();
assert!(matches!(cursor.step(&mut work,&cancel),Err(super::super::TraceError::WorkLimit)));
assert!(cursor.step(&mut work,&cancel).is_err());
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).unwrap();
let used = work.used();
cancel.cancel();
assert!(matches!(cursor.step(&mut work,&cancel),Err(super::super::TraceError::Cancelled)));
assert_eq!(work.used(),used);
assert!(cursor.step(&mut work,&cancel).is_err());
assert!(matches!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel),Err(super::super::TraceError::Cancelled)));
}
#[test]
fn direct_connection_rejects_hostile_optics_before_any_dark_result() {
let source = direct_capture(vec![direct_box("glass",0.0)]);
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(10000).unwrap();
for value in [f64::NAN, f64::INFINITY, -1.0, 101.0] {
let material = DielectricOptions { absorption_per_mm:[value,0.0,0.0], ..direct_material(0,"glass",DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics:vec![material], ..Default::default() };
assert!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).is_err());
}
let material = DielectricOptions { ior:f64::NAN, ..direct_material(0,"glass",DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics:vec![material], ..Default::default() };
assert!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).is_err());
}
#[test]
fn direct_connection_accepts_all_eight_disjoint_boxes_and_preserves_reverse_rgb() {
let bodies = (0..8).map(|i| direct_box(&format!("slab{i}"),f64::from(i)*2.0)).collect();
let source = direct_capture(bodies);
let dielectrics = (0..8).map(|i| direct_material(i,&format!("slab{i}"),DielectricBoundary::ClosedBox)).collect();
let options = RaytraceOptions { dielectrics, ..Default::default() };
let expected = Vec3::new(1.0/256.0,1.0/65536.0,1.0);
direct_rgb(direct_complete(&source,&options,direct_ray(20.0)),expected);
let reverse = super::super::Ray::new(Vec3::new(0.25,0.125,18.0),-Vec3::Z,0.0,20.0).unwrap();
direct_rgb(direct_complete(&source,&options,reverse),expected);
let mut too_many = options.clone();
too_many.dielectrics.push(direct_material(8,"ninth",DielectricBoundary::ClosedBox));
let cancel=CancelToken::default();
let mut work=super::super::WorkBudget::new(1000).unwrap();
assert!(DirectVisibility::new(&source,&too_many,direct_ray(20.0),16,&mut work,&cancel).is_err());
}
#[test]
fn direct_connection_box_edge_is_refused_without_a_successful_fallback() {
let source=direct_capture(vec![direct_box("glass",0.0)]);
let options=RaytraceOptions {dielectrics:vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)],..Default::default()};
let ray=super::super::Ray::new(Vec3::new(-10.0,-10.0,-2.0),Vec3::Z,0.0,10.0).unwrap();
let cancel=CancelToken::default();
let mut work=super::super::WorkBudget::new(1000).unwrap();
let mut cursor=DirectVisibility::new(&source,&options,ray,2,&mut work,&cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work,&cancel) {
Ok(None)=>{},
Err(super::super::TraceError::InvalidInput(_))=>return,
other=>panic!("box edge must refuse: {other:?}"),
}
}
panic!("edge ambiguity did not stop");
}
#[test]
fn direct_connection_setup_certification_cannot_escape_caller_work_limit() {
let source=direct_capture(vec![direct_box("glass",0.0)]);
let options=RaytraceOptions {dielectrics:vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)],..Default::default()};
let cancel=CancelToken::default();
let mut work=super::super::WorkBudget::new(1).unwrap();
assert!(matches!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel),Err(super::super::TraceError::WorkLimit)));
assert_eq!(work.used(),1);
}
#[test]
fn direct_oblique_sheet_is_one_certified_plane_with_original_beer_angle() {
let mut body=direct_sheet("oblique",0.0);
for p in &mut Arc::make_mut(&mut body.mesh).positions { p.z += 0.5*p.x; }
let source=direct_capture(vec![body]);
let options=RaytraceOptions {dielectrics:vec![direct_material(0,"oblique",DielectricBoundary::ThinSheet)],..Default::default()};
let ray=super::super::Ray::new(Vec3::new(0.25,0.25,-2.0),Vec3::Z,0.0,10.0).unwrap();
// Plane z=x/2, index matched: thickness/cos(theta)=sqrt(5)/2 mm.
let distance=5.0_f64.sqrt()/2.0;
direct_rgb(direct_complete(&source,&options,ray),Vec3::new(2.0_f64.powf(-distance),4.0_f64.powf(-distance),1.0));
}
@@ -0,0 +1,509 @@
--- a/crates/render/src/raytrace/dielectric.rs
+++ b/crates/render/src/raytrace/dielectric.rs
@@ -386,6 +386,251 @@
Ok(ExactBoxFace { point, axis, outward, entering: direction * outward < 0.0 })
}
+/// Hard crossing ceiling for eight disjoint certified boxes (two faces each).
+pub const MAX_DIRECT_CROSSINGS: u8 = (MAX_OVERRIDES * 2) as u8;
+
+#[derive(Clone, Copy)]
+struct DirectHit {
+ body: usize,
+ vertices: [Vec3; 3],
+ geometry: super::TriangleHit,
+}
+
+/// Incremental RGB visibility along ONE straight exterior-to-exterior connection.
+/// This is a direct-light approximation, not refracted transport or caustics.
+/// Selected optics are revalidated and certified against the borrowed capture.
+/// Boxes must be disjoint; sheets touching/inside boxes and endpoints on/inside
+/// boxes refuse. Unselected geometry blocks. The interval is [min,max): geometry
+/// exactly at a finite light endpoint does not block that light.
+///
+/// No BVH tie API exists for this consumer yet. A pass scans the captured source,
+/// one triangle (or body transition) per step. Equal-depth different bodies refuse
+/// rather than silently skipping an opaque blocker behind a selected boundary.
+/// Cost is O((crossings+1)*(triangles+bodies)), not accelerated traversal. Every
+/// step debits the caller's cumulative work and checks cancellation. Memory is
+/// fixed, with borrowed scene/options. Valid setup/steps create no containers;
+/// invalid options may construct the existing validator's bounded error String.
+/// The future prepared-scene consumer passes its existing `PreparedScene.source`.
+pub struct DirectVisibility<'a> {
+ source: &'a ProductScene,
+ options: &'a crate::job::RaytraceOptions,
+ certificates: [Option<Certificate>; MAX_OVERRIDES],
+ ray: super::Ray,
+ body: usize,
+ triangle: usize,
+ scanned: usize,
+ nearest: Option<DirectHit>,
+ ambiguous: bool,
+ optical: bool,
+ medium: Option<(usize, Vec3)>,
+ sheets: u8,
+ transmittance: Vec3,
+ crossings: u8,
+ crossing_limit: u8,
+ terminal: bool,
+}
+
+impl<'a> DirectVisibility<'a> {
+ /// Bounded setup: at most eight small certificates and 64 overlap checks.
+ /// Setup also spends the SAME caller-owned budget; no fresh work credit.
+ pub fn new(
+ source: &'a ProductScene,
+ options: &'a crate::job::RaytraceOptions,
+ ray: super::Ray,
+ crossing_limit: u8,
+ work: &mut super::WorkBudget,
+ cancel: &crate::job::CancelToken,
+ ) -> Result<Self> {
+ work.tick(cancel)?;
+ if !(1..=MAX_DIRECT_CROSSINGS).contains(&crossing_limit) || source.meshes.len() > crate::job::MAX_CPU_BODIES {
+ return Err(TraceError::InvalidInput("direct visibility crossing or body bound"));
+ }
+ options.validate().map_err(|_| TraceError::InvalidInput("invalid direct visibility options"))?;
+ let mut certificates = [None; MAX_OVERRIDES];
+ let start = ray.at(ray.interval()[0])?;
+ let end = ray.at(ray.interval()[1])?;
+ for (slot, input) in certificates.iter_mut().zip(&options.dielectrics) {
+ work.tick(cancel)?;
+ let certificate = certify(source, input)?;
+ if let Certificate::ClosedBox(bounds) = certificate {
+ if contains(bounds, ray.origin()) || contains(bounds, start) || contains(bounds, end) {
+ return Err(TraceError::InvalidInput("direct visibility endpoints must be outside certified boxes"));
+ }
+ }
+ *slot = Some(certificate);
+ }
+ for (i, a) in certificates.iter().take(options.dielectrics.len()).enumerate() {
+ for (j, b) in certificates.iter().take(options.dielectrics.len()).enumerate().skip(i + 1) {
+ work.tick(cancel)?;
+ let (Some(a), Some(b)) = (a, b) else { continue };
+ let bounds = |slot: usize, certificate: Certificate| -> Result<Bounds> {
+ match certificate {
+ Certificate::ClosedBox(bounds) => Ok(bounds),
+ Certificate::ThinSheet => {
+ let input = options.dielectrics.get(slot).ok_or(TraceError::Internal("direct certificate slot"))?;
+ let body = source.meshes.get(input.body_index).ok_or(TraceError::Internal("direct certificate body"))?;
+ let bounds = body.mesh.bounds();
+ Bounds::new(bounds.min, bounds.max)
+ }
+ }
+ };
+ if (matches!(a, Certificate::ClosedBox(_)) || matches!(b, Certificate::ClosedBox(_)))
+ && overlaps(bounds(i, *a)?, bounds(j, *b)?)
+ {
+ return Err(TraceError::InvalidInput("direct visibility boxes must be disjoint from other selected boundaries"));
+ }
+ }
+ }
+ super::check_cancel(cancel)?;
+ Ok(Self {
+ source, options, certificates, ray, body: 0, triangle: 0, scanned: 0,
+ nearest: None, ambiguous: false, optical: false, medium: None, sheets: 0,
+ transmittance: Vec3::new(1.0, 1.0, 1.0), crossings: 0, crossing_limit, terminal: false,
+ })
+ }
+
+ /// None means real work remains. Only Some(rgb) is a completed connection.
+ /// Work/crossing exhaustion, cancellation and ambiguity are errors, never black
+ /// or white fallback values. Success OR error makes this cursor terminal.
+ pub fn step(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
+ if self.terminal {
+ return Err(TraceError::InvalidInput("direct visibility cursor is terminal"));
+ }
+ let result = self.advance(work, cancel);
+ if !matches!(result, Ok(None)) {
+ self.terminal = true;
+ }
+ result
+ }
+
+ fn advance(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
+ work.tick(cancel)?;
+ if !self.optical {
+ if let Some(body) = self.source.meshes.get(self.body) {
+ // A certified sheet is ONE plane. Its two triangles can round
+ // their depths differently at a shared edge; it must never be
+ // attenuated twice. Skip that entire immutable certified body
+ // after its first crossing, not a numerical distance interval.
+ if self.options.dielectrics.iter().enumerate().any(|(slot, input)| input.body_index == self.body && self.sheets & (1u8 << slot) != 0) {
+ self.body += 1;
+ self.triangle = 0;
+ return Ok(None);
+ }
+ if let Some(indices) = body.mesh.triangles.get(self.triangle) {
+ self.scanned = self.scanned.checked_add(1).filter(|n| *n <= crate::job::MAX_CPU_TRIANGLES)
+ .ok_or(TraceError::WorkLimit)?;
+ let mut vertices = [Vec3::ZERO; 3];
+ for (point, index) in vertices.iter_mut().zip(indices) {
+ *point = *body.mesh.positions.get(*index as usize).ok_or(TraceError::InvalidInput("direct visibility vertex index"))?;
+ }
+ self.triangle += 1;
+ if let Some(geometry) = Triangle::new(vertices)?.intersect(&self.ray) {
+ if geometry.distance < self.ray.interval()[1] {
+ let hit = DirectHit { body: self.body, vertices, geometry };
+ match self.nearest {
+ None => self.nearest = Some(hit),
+ Some(old) if geometry.distance < old.geometry.distance => {
+ self.nearest = Some(hit);
+ self.ambiguous = false;
+ }
+ Some(old) if geometry.distance == old.geometry.distance && old.body != self.body => self.ambiguous = true,
+ _ => {}
+ }
+ }
+ }
+ } else {
+ self.body += 1;
+ self.triangle = 0;
+ }
+ return Ok(None);
+ }
+ self.optical = true;
+ return Ok(None);
+ }
+ if self.ambiguous {
+ return Err(TraceError::InvalidInput("coincident direct visibility bodies are ambiguous"));
+ }
+ let Some(hit) = self.nearest else {
+ return if self.medium.is_some() {
+ Err(TraceError::InvalidInput("direct visibility missed its certified box exit"))
+ } else {
+ Ok(Some(self.transmittance))
+ };
+ };
+ let Some((slot, input)) = self.options.dielectrics.iter().enumerate().find(|(_, input)| input.body_index == hit.body) else {
+ return Ok(Some(Vec3::ZERO));
+ };
+ if self.crossings == self.crossing_limit {
+ return Err(TraceError::WorkLimit);
+ }
+ let outward = hit.geometry.geometric_normal;
+ let entering = outward.dot(self.ray.direction()) < 0.0;
+ let normal = if entering { outward } else { -outward };
+ match self.certificates.get(slot).copied().flatten().ok_or(TraceError::Internal("direct visibility certificate missing"))? {
+ Certificate::ThinSheet => {
+ if self.medium.is_some() {
+ return Err(TraceError::InvalidInput("direct visibility sheet inside a box"));
+ }
+ let (_, _, transmission) = thin_sheet(self.ray.direction(), normal, input)?;
+ self.transmittance = multiply(self.transmittance, transmission);
+ self.sheets |= 1u8 << slot;
+ }
+ Certificate::ClosedBox(bounds) => {
+ let [a, b, c] = hit.vertices;
+ let [wa, wb, wc] = hit.geometry.barycentric;
+ let face = exact_box_face(bounds, hit.vertices, outward, a * wa + b * wb + c * wc, self.ray)?;
+ // External-angle Fresnel on BOTH straight connections. The ray is
+ // not bent, so an internal critical angle here would invent TIR.
+ let transmit = 1.0 - interface(self.ray.direction(), normal, 1.0, input.ior)?.reflectance;
+ self.transmittance = self.transmittance * transmit;
+ if entering {
+ if self.medium.is_some() {
+ return Err(TraceError::InvalidInput("direct visibility entered overlapping boxes"));
+ }
+ self.medium = Some((slot, face.point));
+ } else {
+ let (active, start) = self.medium.ok_or(TraceError::InvalidInput("direct visibility exited without entry"))?;
+ if active != slot {
+ return Err(TraceError::InvalidInput("direct visibility box exit identity mismatch"));
+ }
+ let delta = face.point - start;
+ let scale = delta.x.abs().max(delta.y.abs()).max(delta.z.abs());
+ if scale == 0.0 || !scale.is_finite() {
+ return Err(TraceError::InvalidInput("direct visibility box segment is degenerate"));
+ }
+ let distance = scale * (delta / scale).len2().sqrt();
+ self.transmittance = multiply(self.transmittance, attenuation(input.absorption_per_mm, distance)?);
+ self.medium = None;
+ }
+ }
+ }
+ if !self.transmittance.is_finite() || [self.transmittance.x, self.transmittance.y, self.transmittance.z].into_iter().any(|v| !(0.0..=1.0).contains(&v)) {
+ return Err(TraceError::InvalidInput("invalid direct visibility transmittance"));
+ }
+ self.crossings += 1;
+ if self.transmittance == Vec3::ZERO {
+ return Ok(Some(Vec3::ZERO));
+ }
+ // Preserve the original origin AND normalized direction. Calling Ray::new
+ // again could renormalize it and change the tie/interval calculations.
+ self.ray.min = hit.geometry.distance.next_up();
+ if self.ray.min >= self.ray.max {
+ return if self.medium.is_some() {
+ Err(TraceError::InvalidInput("direct visibility interval cannot resolve the exit"))
+ } else {
+ Ok(Some(self.transmittance))
+ };
+ }
+ self.body = 0;
+ self.triangle = 0;
+ self.scanned = 0;
+ self.nearest = None;
+ self.ambiguous = false;
+ self.optical = false;
+ Ok(None)
+ }
+}
+
#[cfg(test)]
#[path = "dielectric_tests.rs"]
pub(super) mod tests;
--- a/crates/render/src/raytrace/dielectric_tests.rs
+++ b/crates/render/src/raytrace/dielectric_tests.rs
@@ -735,3 +735,252 @@
assert!(face.spawn(-Vec3::Z, false).is_err());
assert!(face.spawn(Vec3::Z, true).is_err());
}
+
+fn direct_capture(bodies: Vec<ProductMesh>) -> ProductScene {
+ let mut bounds = bodies[0].mesh.bounds();
+ for body in &bodies[1..] {
+ let next = body.mesh.bounds();
+ bounds.min = bounds.min.min(next.min);
+ bounds.max = bounds.max.max(next.max);
+ }
+ ProductScene { meshes: bodies, bounds }
+}
+
+fn direct_box(name: &str, offset: f64) -> ProductMesh {
+ let mut body = box_body(1.0);
+ body.name = name.into();
+ for p in &mut Arc::make_mut(&mut body.mesh).positions { p.z += offset; }
+ body
+}
+
+fn direct_sheet(name: &str, offset: f64) -> ProductMesh {
+ let mut body = direct_box(name, offset);
+ let mesh = Arc::make_mut(&mut body.mesh);
+ mesh.triangles.truncate(2);
+ mesh.positions.truncate(4);
+ body
+}
+
+fn direct_material(body: usize, name: &str, boundary: DielectricBoundary) -> DielectricOptions {
+ DielectricOptions { body_index: body, body_name: name.into(), boundary, ior: 1.0,
+ absorption_per_mm: [2.0_f64.ln(), 4.0_f64.ln(), 0.0],
+ thickness_mm: if boundary == DielectricBoundary::ThinSheet { 1.0 } else { 0.0 } }
+}
+
+fn direct_ray(max: f64) -> super::super::Ray {
+ super::super::Ray::new(Vec3::new(0.25, 0.125, -2.0), Vec3::Z, 0.0, max).unwrap()
+}
+
+fn direct_complete(source: &ProductScene, options: &RaytraceOptions, ray: super::super::Ray) -> Vec3 {
+ let cancel = CancelToken::default();
+ let mut work = super::super::WorkBudget::new(100_000).unwrap();
+ let mut visibility = DirectVisibility::new(source, options, ray, MAX_DIRECT_CROSSINGS, &mut work, &cancel).unwrap();
+ for _ in 0..10_000 {
+ if let Some(rgb) = visibility.step(&mut work, &cancel).unwrap() {
+ assert!(visibility.step(&mut work, &cancel).is_err());
+ return rgb;
+ }
+ }
+ panic!("bounded direct connection did not complete");
+}
+
+fn direct_rgb(actual: Vec3, expected: Vec3) {
+ for (a, b) in [actual.x, actual.y, actual.z].into_iter().zip([expected.x, expected.y, expected.z]) {
+ assert!((a - b).abs() <= 1e-13, "actual={actual:?}, expected={expected:?}");
+ }
+}
+
+#[test]
+fn direct_box_beer_uses_original_endpoints_and_multiplies_disjoint_rgb() {
+ let source = direct_capture(vec![direct_box("one", 0.0), direct_box("two", 3.0)]);
+ let first = direct_material(0, "one", DielectricBoundary::ClosedBox);
+ let second = direct_material(1, "two", DielectricBoundary::ClosedBox);
+ let options = RaytraceOptions { dielectrics: vec![first, second], ..Default::default() };
+ // sigma=(ln2,ln4,0), two one-mm slabs: exp(-2*sigma)=(1/4,1/16,1).
+ direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), Vec3::new(0.25, 0.0625, 1.0));
+ let one = direct_capture(vec![direct_box("one", 0.0)]);
+ let options = RaytraceOptions { dielectrics: vec![direct_material(0, "one", DielectricBoundary::ClosedBox)], ..Default::default() };
+ direct_rgb(direct_complete(&one, &options, direct_ray(10.0)), Vec3::new(0.5, 0.25, 1.0));
+}
+
+#[test]
+fn direct_sheet_shared_diagonal_is_one_crossing_and_uses_virtual_beer_distance() {
+ let source = direct_capture(vec![direct_sheet("sheet", 0.0)]);
+ let options = RaytraceOptions { dielectrics: vec![direct_material(0, "sheet", DielectricBoundary::ThinSheet)], ..Default::default() };
+ let ray = super::super::Ray::new(Vec3::new(0.25, 0.25, -2.0), Vec3::Z, 0.0, 10.0).unwrap();
+ direct_rgb(direct_complete(&source, &options, ray), Vec3::new(0.5, 0.25, 1.0));
+}
+
+#[test]
+fn direct_exterior_box_fresnel_is_not_a_refracted_or_infinite_bounce_slab() {
+ let source = direct_capture(vec![direct_box("glass", 0.0)]);
+ let material = DielectricOptions { ior: 1.5, absorption_per_mm: [0.0; 3], ..direct_material(0, "glass", DielectricBoundary::ClosedBox) };
+ let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
+ // Normal external R=1/25; this straight connection has (24/25)^2, with no reflected returns.
+ direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), Vec3::new(576.0/625.0, 576.0/625.0, 576.0/625.0));
+ let sheet = direct_capture(vec![direct_sheet("sheet", 0.0)]);
+ let material = DielectricOptions { ior: 1.5, absorption_per_mm: [0.0; 3], ..direct_material(0, "sheet", DielectricBoundary::ThinSheet) };
+ let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
+ // Existing incoherent sheet closure sums its virtual internal returns: (1-R)/(1+R)=12/13.
+ direct_rgb(direct_complete(&sheet, &options, direct_ray(10.0)), Vec3::new(12.0/13.0, 12.0/13.0, 12.0/13.0));
+}
+
+#[test]
+fn direct_connection_keeps_unselected_opaque_blockers_and_half_open_light_endpoint() {
+ let options = RaytraceOptions::default();
+ for (z, expected) in [(7.0, Vec3::ZERO), (8.0, Vec3::new(1.0,1.0,1.0)), (9.0, Vec3::new(1.0,1.0,1.0))] {
+ let source = direct_capture(vec![direct_sheet("opaque", z)]);
+ direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), expected);
+ }
+ let source = direct_capture(vec![direct_box("glass", 0.0), direct_sheet("blocker", 5.0)]);
+ let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
+ assert_eq!(direct_complete(&source, &options, direct_ray(10.0)), Vec3::ZERO);
+}
+
+#[test]
+fn direct_connection_refuses_coincident_different_bodies_instead_of_skipping_opaque() {
+ let source = direct_capture(vec![direct_sheet("selected", 0.0), direct_sheet("opaque", 0.0)]);
+ let options = RaytraceOptions { dielectrics: vec![direct_material(0, "selected", DielectricBoundary::ThinSheet)], ..Default::default() };
+ let cancel = CancelToken::default();
+ let mut work = super::super::WorkBudget::new(100_000).unwrap();
+ let mut cursor = DirectVisibility::new(&source, &options, direct_ray(10.0), MAX_DIRECT_CROSSINGS, &mut work, &cancel).unwrap();
+ for _ in 0..100 {
+ match cursor.step(&mut work, &cancel) {
+ Ok(None) => {}
+ Err(super::super::TraceError::InvalidInput(_)) => {
+ assert!(cursor.step(&mut work, &cancel).is_err());
+ return;
+ }
+ other => panic!("ambiguous visibility must refuse: {other:?}"),
+ }
+ }
+ panic!("coincident boundary did not refuse");
+}
+
+#[test]
+fn direct_connection_bounds_validate_identity_endpoints_overlap_and_crossing_budget() {
+ let source = direct_capture(vec![direct_box("glass", 0.0)]);
+ let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
+ let cancel = CancelToken::default();
+ let mut work = super::super::WorkBudget::new(100_000).unwrap();
+ for limit in [0, MAX_DIRECT_CROSSINGS+1] {
+ assert!(DirectVisibility::new(&source, &options, direct_ray(10.0), limit, &mut work, &cancel).is_err());
+ }
+ // End at entry face, inside, and at exit face: all explicitly outside this foundation's domain.
+ for max in [2.0, 2.5, 3.0] {
+ assert!(DirectVisibility::new(&source, &options, direct_ray(max), 2, &mut work, &cancel).is_err());
+ }
+ let inside = super::super::Ray::new(Vec3::new(0.25, 0.125, 0.5), Vec3::Z, 0.0, 10.0).unwrap();
+ assert!(DirectVisibility::new(&source, &options, inside, 2, &mut work, &cancel).is_err());
+ let mut wrong = options.clone();wrong.dielectrics[0].body_name="wrong".into();
+ assert!(DirectVisibility::new(&source, &wrong, direct_ray(10.0), 2, &mut work, &cancel).is_err());
+ let overlap = direct_capture(vec![direct_box("a", 0.0), direct_box("b", 0.5)]);
+ let overlapping = RaytraceOptions { dielectrics: vec![direct_material(0,"a",DielectricBoundary::ClosedBox),direct_material(1,"b",DielectricBoundary::ClosedBox)], ..Default::default() };
+ assert!(DirectVisibility::new(&overlap,&overlapping,direct_ray(10.0),4,&mut work,&cancel).is_err());
+ let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),1,&mut work,&cancel).unwrap();
+ for _ in 0..100 {
+ match cursor.step(&mut work,&cancel) {
+ Ok(None) => {}
+ Err(super::super::TraceError::WorkLimit) => { assert!(cursor.step(&mut work,&cancel).is_err());return; }
+ other => panic!("insufficient crossing budget must refuse: {other:?}"),
+ }
+ }
+ panic!("crossing cap did not stop");
+}
+
+#[test]
+fn direct_connection_spends_caller_work_and_cancellation_is_terminal_without_result() {
+ let source = direct_capture(vec![direct_box("glass",0.0)]);
+ let options = RaytraceOptions { dielectrics: vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)], ..Default::default() };
+ let cancel = CancelToken::default();
+ let mut work = super::super::WorkBudget::new(1000).unwrap();
+ let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).unwrap();
+ let used = work.used();
+ assert!(used>0);
+ assert_eq!(cursor.step(&mut work,&cancel).unwrap(),None);
+ assert_eq!(work.used(),used+1);
+ work.charge(work.remaining(),&cancel).unwrap();
+ assert!(matches!(cursor.step(&mut work,&cancel),Err(super::super::TraceError::WorkLimit)));
+ assert!(cursor.step(&mut work,&cancel).is_err());
+ let cancel = CancelToken::default();
+ let mut work = super::super::WorkBudget::new(1000).unwrap();
+ let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).unwrap();
+ let used = work.used();
+ cancel.cancel();
+ assert!(matches!(cursor.step(&mut work,&cancel),Err(super::super::TraceError::Cancelled)));
+ assert_eq!(work.used(),used);
+ assert!(cursor.step(&mut work,&cancel).is_err());
+ assert!(matches!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel),Err(super::super::TraceError::Cancelled)));
+}
+
+#[test]
+fn direct_connection_rejects_hostile_optics_before_any_dark_result() {
+ let source = direct_capture(vec![direct_box("glass",0.0)]);
+ let cancel = CancelToken::default();
+ let mut work = super::super::WorkBudget::new(10000).unwrap();
+ for value in [f64::NAN, f64::INFINITY, -1.0, 101.0] {
+ let material = DielectricOptions { absorption_per_mm:[value,0.0,0.0], ..direct_material(0,"glass",DielectricBoundary::ClosedBox) };
+ let options = RaytraceOptions { dielectrics:vec![material], ..Default::default() };
+ assert!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).is_err());
+ }
+ let material = DielectricOptions { ior:f64::NAN, ..direct_material(0,"glass",DielectricBoundary::ClosedBox) };
+ let options = RaytraceOptions { dielectrics:vec![material], ..Default::default() };
+ assert!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).is_err());
+}
+
+#[test]
+fn direct_connection_accepts_all_eight_disjoint_boxes_and_preserves_reverse_rgb() {
+ let bodies = (0..8).map(|i| direct_box(&format!("slab{i}"),f64::from(i)*2.0)).collect();
+ let source = direct_capture(bodies);
+ let dielectrics = (0..8).map(|i| direct_material(i,&format!("slab{i}"),DielectricBoundary::ClosedBox)).collect();
+ let options = RaytraceOptions { dielectrics, ..Default::default() };
+ let expected = Vec3::new(1.0/256.0,1.0/65536.0,1.0);
+ direct_rgb(direct_complete(&source,&options,direct_ray(20.0)),expected);
+ let reverse = super::super::Ray::new(Vec3::new(0.25,0.125,18.0),-Vec3::Z,0.0,20.0).unwrap();
+ direct_rgb(direct_complete(&source,&options,reverse),expected);
+ let mut too_many = options.clone();
+ too_many.dielectrics.push(direct_material(8,"ninth",DielectricBoundary::ClosedBox));
+ let cancel=CancelToken::default();
+ let mut work=super::super::WorkBudget::new(1000).unwrap();
+ assert!(DirectVisibility::new(&source,&too_many,direct_ray(20.0),16,&mut work,&cancel).is_err());
+}
+
+#[test]
+fn direct_connection_box_edge_is_refused_without_a_successful_fallback() {
+ let source=direct_capture(vec![direct_box("glass",0.0)]);
+ let options=RaytraceOptions {dielectrics:vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)],..Default::default()};
+ let ray=super::super::Ray::new(Vec3::new(-10.0,-10.0,-2.0),Vec3::Z,0.0,10.0).unwrap();
+ let cancel=CancelToken::default();
+ let mut work=super::super::WorkBudget::new(1000).unwrap();
+ let mut cursor=DirectVisibility::new(&source,&options,ray,2,&mut work,&cancel).unwrap();
+ for _ in 0..100 {
+ match cursor.step(&mut work,&cancel) {
+ Ok(None)=>{},
+ Err(super::super::TraceError::InvalidInput(_))=>return,
+ other=>panic!("box edge must refuse: {other:?}"),
+ }
+ }
+ panic!("edge ambiguity did not stop");
+}
+
+#[test]
+fn direct_connection_setup_certification_cannot_escape_caller_work_limit() {
+ let source=direct_capture(vec![direct_box("glass",0.0)]);
+ let options=RaytraceOptions {dielectrics:vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)],..Default::default()};
+ let cancel=CancelToken::default();
+ let mut work=super::super::WorkBudget::new(1).unwrap();
+ assert!(matches!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel),Err(super::super::TraceError::WorkLimit)));
+ assert_eq!(work.used(),1);
+}
+
+#[test]
+fn direct_oblique_sheet_is_one_certified_plane_with_original_beer_angle() {
+ let mut body=direct_sheet("oblique",0.0);
+ for p in &mut Arc::make_mut(&mut body.mesh).positions { p.z += 0.5*p.x; }
+ let source=direct_capture(vec![body]);
+ let options=RaytraceOptions {dielectrics:vec![direct_material(0,"oblique",DielectricBoundary::ThinSheet)],..Default::default()};
+ let ray=super::super::Ray::new(Vec3::new(0.25,0.25,-2.0),Vec3::Z,0.0,10.0).unwrap();
+ // Plane z=x/2, index matched: thickness/cos(theta)=sqrt(5)/2 mm.
+ let distance=5.0_f64.sqrt()/2.0;
+ direct_rgb(direct_complete(&source,&options,ray),Vec3::new(2.0_f64.powf(-distance),4.0_f64.powf(-distance),1.0));
+}
@@ -0,0 +1,245 @@
/// Hard crossing ceiling for eight disjoint certified boxes (two faces each).
pub const MAX_DIRECT_CROSSINGS: u8 = (MAX_OVERRIDES * 2) as u8;
#[derive(Clone, Copy)]
struct DirectHit {
body: usize,
vertices: [Vec3; 3],
geometry: super::TriangleHit,
}
/// Incremental RGB visibility along ONE straight exterior-to-exterior connection.
/// This is a direct-light approximation, not refracted transport or caustics.
/// Selected optics are revalidated and certified against the borrowed capture.
/// Boxes must be disjoint; sheets touching/inside boxes and endpoints on/inside
/// boxes refuse. Unselected geometry blocks. The interval is [min,max): geometry
/// exactly at a finite light endpoint does not block that light.
///
/// No BVH tie API exists for this consumer yet. A pass scans the captured source,
/// one triangle (or body transition) per step. Equal-depth different bodies refuse
/// rather than silently skipping an opaque blocker behind a selected boundary.
/// Cost is O((crossings+1)*(triangles+bodies)), not accelerated traversal. Every
/// step debits the caller's cumulative work and checks cancellation. Memory is
/// fixed, with borrowed scene/options. Valid setup/steps create no containers;
/// invalid options may construct the existing validator's bounded error String.
/// The future prepared-scene consumer passes its existing `PreparedScene.source`.
pub struct DirectVisibility<'a> {
source: &'a ProductScene,
options: &'a crate::job::RaytraceOptions,
certificates: [Option<Certificate>; MAX_OVERRIDES],
ray: super::Ray,
body: usize,
triangle: usize,
scanned: usize,
nearest: Option<DirectHit>,
ambiguous: bool,
optical: bool,
medium: Option<(usize, Vec3)>,
sheets: u8,
transmittance: Vec3,
crossings: u8,
crossing_limit: u8,
terminal: bool,
}
impl<'a> DirectVisibility<'a> {
/// Bounded setup: at most eight small certificates and 64 overlap checks.
/// Setup also spends the SAME caller-owned budget; no fresh work credit.
pub fn new(
source: &'a ProductScene,
options: &'a crate::job::RaytraceOptions,
ray: super::Ray,
crossing_limit: u8,
work: &mut super::WorkBudget,
cancel: &crate::job::CancelToken,
) -> Result<Self> {
work.tick(cancel)?;
if !(1..=MAX_DIRECT_CROSSINGS).contains(&crossing_limit) || source.meshes.len() > crate::job::MAX_CPU_BODIES {
return Err(TraceError::InvalidInput("direct visibility crossing or body bound"));
}
options.validate().map_err(|_| TraceError::InvalidInput("invalid direct visibility options"))?;
let mut certificates = [None; MAX_OVERRIDES];
let start = ray.at(ray.interval()[0])?;
let end = ray.at(ray.interval()[1])?;
for (slot, input) in certificates.iter_mut().zip(&options.dielectrics) {
work.tick(cancel)?;
let certificate = certify(source, input)?;
if let Certificate::ClosedBox(bounds) = certificate {
if contains(bounds, ray.origin()) || contains(bounds, start) || contains(bounds, end) {
return Err(TraceError::InvalidInput("direct visibility endpoints must be outside certified boxes"));
}
}
*slot = Some(certificate);
}
for (i, a) in certificates.iter().take(options.dielectrics.len()).enumerate() {
for (j, b) in certificates.iter().take(options.dielectrics.len()).enumerate().skip(i + 1) {
work.tick(cancel)?;
let (Some(a), Some(b)) = (a, b) else { continue };
let bounds = |slot: usize, certificate: Certificate| -> Result<Bounds> {
match certificate {
Certificate::ClosedBox(bounds) => Ok(bounds),
Certificate::ThinSheet => {
let input = options.dielectrics.get(slot).ok_or(TraceError::Internal("direct certificate slot"))?;
let body = source.meshes.get(input.body_index).ok_or(TraceError::Internal("direct certificate body"))?;
let bounds = body.mesh.bounds();
Bounds::new(bounds.min, bounds.max)
}
}
};
if (matches!(a, Certificate::ClosedBox(_)) || matches!(b, Certificate::ClosedBox(_)))
&& overlaps(bounds(i, *a)?, bounds(j, *b)?)
{
return Err(TraceError::InvalidInput("direct visibility boxes must be disjoint from other selected boundaries"));
}
}
}
super::check_cancel(cancel)?;
Ok(Self {
source, options, certificates, ray, body: 0, triangle: 0, scanned: 0,
nearest: None, ambiguous: false, optical: false, medium: None, sheets: 0,
transmittance: Vec3::new(1.0, 1.0, 1.0), crossings: 0, crossing_limit, terminal: false,
})
}
/// None means real work remains. Only Some(rgb) is a completed connection.
/// Work/crossing exhaustion, cancellation and ambiguity are errors, never black
/// or white fallback values. Success OR error makes this cursor terminal.
pub fn step(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
if self.terminal {
return Err(TraceError::InvalidInput("direct visibility cursor is terminal"));
}
let result = self.advance(work, cancel);
if !matches!(result, Ok(None)) {
self.terminal = true;
}
result
}
fn advance(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
work.tick(cancel)?;
if !self.optical {
if let Some(body) = self.source.meshes.get(self.body) {
// A certified sheet is ONE plane. Its two triangles can round
// their depths differently at a shared edge; it must never be
// attenuated twice. Skip that entire immutable certified body
// after its first crossing, not a numerical distance interval.
if self.options.dielectrics.iter().enumerate().any(|(slot, input)| input.body_index == self.body && self.sheets & (1u8 << slot) != 0) {
self.body += 1;
self.triangle = 0;
return Ok(None);
}
if let Some(indices) = body.mesh.triangles.get(self.triangle) {
self.scanned = self.scanned.checked_add(1).filter(|n| *n <= crate::job::MAX_CPU_TRIANGLES)
.ok_or(TraceError::WorkLimit)?;
let mut vertices = [Vec3::ZERO; 3];
for (point, index) in vertices.iter_mut().zip(indices) {
*point = *body.mesh.positions.get(*index as usize).ok_or(TraceError::InvalidInput("direct visibility vertex index"))?;
}
self.triangle += 1;
if let Some(geometry) = Triangle::new(vertices)?.intersect(&self.ray) {
if geometry.distance < self.ray.interval()[1] {
let hit = DirectHit { body: self.body, vertices, geometry };
match self.nearest {
None => self.nearest = Some(hit),
Some(old) if geometry.distance < old.geometry.distance => {
self.nearest = Some(hit);
self.ambiguous = false;
}
Some(old) if geometry.distance == old.geometry.distance && old.body != self.body => self.ambiguous = true,
_ => {}
}
}
}
} else {
self.body += 1;
self.triangle = 0;
}
return Ok(None);
}
self.optical = true;
return Ok(None);
}
if self.ambiguous {
return Err(TraceError::InvalidInput("coincident direct visibility bodies are ambiguous"));
}
let Some(hit) = self.nearest else {
return if self.medium.is_some() {
Err(TraceError::InvalidInput("direct visibility missed its certified box exit"))
} else {
Ok(Some(self.transmittance))
};
};
let Some((slot, input)) = self.options.dielectrics.iter().enumerate().find(|(_, input)| input.body_index == hit.body) else {
return Ok(Some(Vec3::ZERO));
};
if self.crossings == self.crossing_limit {
return Err(TraceError::WorkLimit);
}
let outward = hit.geometry.geometric_normal;
let entering = outward.dot(self.ray.direction()) < 0.0;
let normal = if entering { outward } else { -outward };
match self.certificates.get(slot).copied().flatten().ok_or(TraceError::Internal("direct visibility certificate missing"))? {
Certificate::ThinSheet => {
if self.medium.is_some() {
return Err(TraceError::InvalidInput("direct visibility sheet inside a box"));
}
let (_, _, transmission) = thin_sheet(self.ray.direction(), normal, input)?;
self.transmittance = multiply(self.transmittance, transmission);
self.sheets |= 1u8 << slot;
}
Certificate::ClosedBox(bounds) => {
let [a, b, c] = hit.vertices;
let [wa, wb, wc] = hit.geometry.barycentric;
let face = exact_box_face(bounds, hit.vertices, outward, a * wa + b * wb + c * wc, self.ray)?;
// External-angle Fresnel on BOTH straight connections. The ray is
// not bent, so an internal critical angle here would invent TIR.
let transmit = 1.0 - interface(self.ray.direction(), normal, 1.0, input.ior)?.reflectance;
self.transmittance = self.transmittance * transmit;
if entering {
if self.medium.is_some() {
return Err(TraceError::InvalidInput("direct visibility entered overlapping boxes"));
}
self.medium = Some((slot, face.point));
} else {
let (active, start) = self.medium.ok_or(TraceError::InvalidInput("direct visibility exited without entry"))?;
if active != slot {
return Err(TraceError::InvalidInput("direct visibility box exit identity mismatch"));
}
let delta = face.point - start;
let scale = delta.x.abs().max(delta.y.abs()).max(delta.z.abs());
if scale == 0.0 || !scale.is_finite() {
return Err(TraceError::InvalidInput("direct visibility box segment is degenerate"));
}
let distance = scale * (delta / scale).len2().sqrt();
self.transmittance = multiply(self.transmittance, attenuation(input.absorption_per_mm, distance)?);
self.medium = None;
}
}
}
if !self.transmittance.is_finite() || [self.transmittance.x, self.transmittance.y, self.transmittance.z].into_iter().any(|v| !(0.0..=1.0).contains(&v)) {
return Err(TraceError::InvalidInput("invalid direct visibility transmittance"));
}
self.crossings += 1;
if self.transmittance == Vec3::ZERO {
return Ok(Some(Vec3::ZERO));
}
// Preserve the original origin AND normalized direction. Calling Ray::new
// again could renormalize it and change the tie/interval calculations.
self.ray.min = hit.geometry.distance.next_up();
if self.ray.min >= self.ray.max {
return if self.medium.is_some() {
Err(TraceError::InvalidInput("direct visibility interval cannot resolve the exit"))
} else {
Ok(Some(self.transmittance))
};
}
self.body = 0;
self.triangle = 0;
self.scanned = 0;
self.nearest = None;
self.ambiguous = false;
self.optical = false;
Ok(None)
}
}
@@ -0,0 +1,137 @@
{
"status": "SOURCE_ONLY_WIP_UNFORMATTED_UNCOMPILED_UNRUN_UNREGISTERED_NO_IMAGE_CONSUMER",
"license": "MIT OR Apache-2.0; original SolidCraft contributors source and original analytic cases; existing authorship retained",
"base": {
"kind": "exact frozen C20 working-source endpoints, not a claim that the published PR HEAD alone has these bytes",
"source_manifest_sha256": "eb6ec43c4fe6862dcf27d9832e8724abf05b3edcdc945f6a8c3e17f35b76d116",
"canonical_C20_source": "../c20-reviewed-composition/README.md",
"observed_archive_commit": "25c9527bbc9aebf5bc7a8191ca21c2bba5722933"
},
"production_paths": [
"crates/render/src/raytrace/dielectric.rs",
"crates/render/src/raytrace/dielectric_tests.rs"
],
"composition": "One insertion before the unchanged existing cfg(test) router; append-only additions to the existing dielectric_tests.rs. Exact patch is an increment on the two before/ endpoints, not an alternative full C20 assembly.",
"endpoints": [
{
"path": "crates/render/src/raytrace/dielectric.rs",
"before_sha256": "2063f440d7a9e495e0a0398ae9bb5084379731473f80d86ffb5be95f38dad0bc",
"candidate_sha256": "9f54aea0947a3e20dfc0aa398fd9372c3d924b1219eeff1f2ba5bb05162a6f85",
"before_bytes": 18121,
"candidate_bytes": 30682,
"eol": "LF"
},
{
"path": "crates/render/src/raytrace/dielectric_tests.rs",
"before_sha256": "129ca64d6ba5df6bcb777814688a6334ed98c32e6068d4274312f3fdff045258",
"candidate_sha256": "15e4280b5783fc564c4f08ccded30152815fdd0f69981efab78e2f67d9d9e1ec",
"before_bytes": 34116,
"candidate_bytes": 47987,
"eol": "LF"
}
],
"delta": {
"path": "delta.patch",
"sha256": "fbc1def87de38d4de325035cbdacfbd7d3259a2bfb64308bdec708449c972390",
"exact_byte_replay": true
},
"fragments": [
{
"path": "production-addition.rs.fragment",
"sha256": "1b09c318f5d77e60124f2c623d9ebcf1852bc81bb12f76666e4eb3602fd5a775"
},
{
"path": "test-additions.rs.fragment",
"sha256": "ff0578248b616f3b51c5e0166bfbfcae1e4264974f992f9d4b219ac4c199305b"
}
],
"actual_reference_source": [
{
"path": "crates/render/src/raytrace/mod.rs",
"sha256": "d7f8ed95a60384d1e8f896ec0637af39682778adf4546c646104ae22ab53fbb8",
"bytes": 18244,
"role": "reference-only, not changed"
},
{
"path": "crates/render/src/raytrace/bvh.rs",
"sha256": "b4b333e2b39e4c89714637519ab8023fc88efd341906d9a8382068388ad0985d",
"bytes": 18238,
"role": "reference-only, not changed"
},
{
"path": "crates/render/src/raytrace/prepared.rs",
"sha256": "14e258d73474ae112988cfac4000ed73e543da7b1af6e5b51ee97afa5b37a583",
"bytes": 4511,
"role": "reference-only, not changed"
},
{
"path": "crates/render/src/job/mod.rs",
"sha256": "7642c9eeb9877a695a2a2a332e941cd1ffcdf598f5aa5f77fcbcb17a663c553f",
"bytes": 70575,
"role": "reference-only, not changed"
},
{
"path": "crates/render/src/surface.rs",
"sha256": "024972ae3e069453dd40b2d881d8540fb7a203033fa7bb4aa1663ac21d6bb987",
"bytes": 12603,
"role": "reference-only, not changed"
},
{
"path": "crates/render/src/job/host.rs",
"sha256": "647c2d5d4453ee3028818f1088531b18aff17aa639168ac05c9d206028e86c78",
"bytes": 116953,
"role": "reference-only, not changed"
},
{
"path": "crates/render/src/job/cpu_budget.rs",
"sha256": "7756fedf76658a1eedd0c0194aa98141d33464c0f7df85fe86f9e255fd8b8ce2",
"bytes": 7771,
"role": "reference-only numeric caps, not changed"
}
],
"component_contract": {
"max_selected_bodies": 8,
"crossing_limit_inclusive": [
1,
16
],
"per_pass_triangle_cap": 8000000,
"body_cap": 262144,
"work": "Same caller-owned cumulative WorkBudget for construction and every step; caller must not replace/reset the ledger. No private work credit is created.",
"cost": "O((crossings+1)*(triangles+bodies)); incremental actual source scan, not BVH-accelerated",
"memory": "Fixed arrays/scalars plus borrowed references; valid setup/steps create no new containers. Existing invalid-options validator may allocate a bounded error String; compiled layout/RSS and future consumer admission are unverified.",
"connection": "one straight exterior-to-exterior segment, interval [min,max); existing ray origin/direction retained, lower bound next_up(hit.distance)",
"errors": "Existing TraceError InvalidInput/Internal/WorkLimit/Cancelled; no white/black success substituted for pending/errors; cursor terminal after success or any step error",
"consumer_installed": false
},
"acceptance": {
"old_declarations_preserved": 33,
"new_declarations": 12,
"all_45_UNRUN_on_this_candidate": true,
"fixed_original_channel_tolerance": 1e-13,
"baseline_expectations_or_tolerances_changed": false
},
"not_claimed": [
"Rust typechecking",
"formatting",
"compilation",
"executed tests",
"current runtime",
"heap/stack layout or performance",
"image shadows repaired",
"general glass",
"caustics",
"GPU parity",
"full CI",
"ready or merged"
],
"integration_obligations": [
"fresh exact receiver/API binding before adoption",
"independent source review is not execution",
"real PreparedScene.source borrowing image consumer",
"same cumulative work/cancellation and structured error propagation",
"component metadata/copies/retained capacity included in current memory admission",
"actual progressive scheduling",
"original component tests plus affected/dependent gate and real image/native acceptance"
]
}
@@ -0,0 +1,249 @@
fn direct_capture(bodies: Vec<ProductMesh>) -> ProductScene {
let mut bounds = bodies[0].mesh.bounds();
for body in &bodies[1..] {
let next = body.mesh.bounds();
bounds.min = bounds.min.min(next.min);
bounds.max = bounds.max.max(next.max);
}
ProductScene { meshes: bodies, bounds }
}
fn direct_box(name: &str, offset: f64) -> ProductMesh {
let mut body = box_body(1.0);
body.name = name.into();
for p in &mut Arc::make_mut(&mut body.mesh).positions { p.z += offset; }
body
}
fn direct_sheet(name: &str, offset: f64) -> ProductMesh {
let mut body = direct_box(name, offset);
let mesh = Arc::make_mut(&mut body.mesh);
mesh.triangles.truncate(2);
mesh.positions.truncate(4);
body
}
fn direct_material(body: usize, name: &str, boundary: DielectricBoundary) -> DielectricOptions {
DielectricOptions { body_index: body, body_name: name.into(), boundary, ior: 1.0,
absorption_per_mm: [2.0_f64.ln(), 4.0_f64.ln(), 0.0],
thickness_mm: if boundary == DielectricBoundary::ThinSheet { 1.0 } else { 0.0 } }
}
fn direct_ray(max: f64) -> super::super::Ray {
super::super::Ray::new(Vec3::new(0.25, 0.125, -2.0), Vec3::Z, 0.0, max).unwrap()
}
fn direct_complete(source: &ProductScene, options: &RaytraceOptions, ray: super::super::Ray) -> Vec3 {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut visibility = DirectVisibility::new(source, options, ray, MAX_DIRECT_CROSSINGS, &mut work, &cancel).unwrap();
for _ in 0..10_000 {
if let Some(rgb) = visibility.step(&mut work, &cancel).unwrap() {
assert!(visibility.step(&mut work, &cancel).is_err());
return rgb;
}
}
panic!("bounded direct connection did not complete");
}
fn direct_rgb(actual: Vec3, expected: Vec3) {
for (a, b) in [actual.x, actual.y, actual.z].into_iter().zip([expected.x, expected.y, expected.z]) {
assert!((a - b).abs() <= 1e-13, "actual={actual:?}, expected={expected:?}");
}
}
#[test]
fn direct_box_beer_uses_original_endpoints_and_multiplies_disjoint_rgb() {
let source = direct_capture(vec![direct_box("one", 0.0), direct_box("two", 3.0)]);
let first = direct_material(0, "one", DielectricBoundary::ClosedBox);
let second = direct_material(1, "two", DielectricBoundary::ClosedBox);
let options = RaytraceOptions { dielectrics: vec![first, second], ..Default::default() };
// sigma=(ln2,ln4,0), two one-mm slabs: exp(-2*sigma)=(1/4,1/16,1).
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), Vec3::new(0.25, 0.0625, 1.0));
let one = direct_capture(vec![direct_box("one", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "one", DielectricBoundary::ClosedBox)], ..Default::default() };
direct_rgb(direct_complete(&one, &options, direct_ray(10.0)), Vec3::new(0.5, 0.25, 1.0));
}
#[test]
fn direct_sheet_shared_diagonal_is_one_crossing_and_uses_virtual_beer_distance() {
let source = direct_capture(vec![direct_sheet("sheet", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "sheet", DielectricBoundary::ThinSheet)], ..Default::default() };
let ray = super::super::Ray::new(Vec3::new(0.25, 0.25, -2.0), Vec3::Z, 0.0, 10.0).unwrap();
direct_rgb(direct_complete(&source, &options, ray), Vec3::new(0.5, 0.25, 1.0));
}
#[test]
fn direct_exterior_box_fresnel_is_not_a_refracted_or_infinite_bounce_slab() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let material = DielectricOptions { ior: 1.5, absorption_per_mm: [0.0; 3], ..direct_material(0, "glass", DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
// Normal external R=1/25; this straight connection has (24/25)^2, with no reflected returns.
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), Vec3::new(576.0/625.0, 576.0/625.0, 576.0/625.0));
let sheet = direct_capture(vec![direct_sheet("sheet", 0.0)]);
let material = DielectricOptions { ior: 1.5, absorption_per_mm: [0.0; 3], ..direct_material(0, "sheet", DielectricBoundary::ThinSheet) };
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
// Existing incoherent sheet closure sums its virtual internal returns: (1-R)/(1+R)=12/13.
direct_rgb(direct_complete(&sheet, &options, direct_ray(10.0)), Vec3::new(12.0/13.0, 12.0/13.0, 12.0/13.0));
}
#[test]
fn direct_connection_keeps_unselected_opaque_blockers_and_half_open_light_endpoint() {
let options = RaytraceOptions::default();
for (z, expected) in [(7.0, Vec3::ZERO), (8.0, Vec3::new(1.0,1.0,1.0)), (9.0, Vec3::new(1.0,1.0,1.0))] {
let source = direct_capture(vec![direct_sheet("opaque", z)]);
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), expected);
}
let source = direct_capture(vec![direct_box("glass", 0.0), direct_sheet("blocker", 5.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
assert_eq!(direct_complete(&source, &options, direct_ray(10.0)), Vec3::ZERO);
}
#[test]
fn direct_connection_refuses_coincident_different_bodies_instead_of_skipping_opaque() {
let source = direct_capture(vec![direct_sheet("selected", 0.0), direct_sheet("opaque", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "selected", DielectricBoundary::ThinSheet)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut cursor = DirectVisibility::new(&source, &options, direct_ray(10.0), MAX_DIRECT_CROSSINGS, &mut work, &cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work, &cancel) {
Ok(None) => {}
Err(super::super::TraceError::InvalidInput(_)) => {
assert!(cursor.step(&mut work, &cancel).is_err());
return;
}
other => panic!("ambiguous visibility must refuse: {other:?}"),
}
}
panic!("coincident boundary did not refuse");
}
#[test]
fn direct_connection_bounds_validate_identity_endpoints_overlap_and_crossing_budget() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
for limit in [0, MAX_DIRECT_CROSSINGS+1] {
assert!(DirectVisibility::new(&source, &options, direct_ray(10.0), limit, &mut work, &cancel).is_err());
}
// End at entry face, inside, and at exit face: all explicitly outside this foundation's domain.
for max in [2.0, 2.5, 3.0] {
assert!(DirectVisibility::new(&source, &options, direct_ray(max), 2, &mut work, &cancel).is_err());
}
let inside = super::super::Ray::new(Vec3::new(0.25, 0.125, 0.5), Vec3::Z, 0.0, 10.0).unwrap();
assert!(DirectVisibility::new(&source, &options, inside, 2, &mut work, &cancel).is_err());
let mut wrong = options.clone();wrong.dielectrics[0].body_name="wrong".into();
assert!(DirectVisibility::new(&source, &wrong, direct_ray(10.0), 2, &mut work, &cancel).is_err());
let overlap = direct_capture(vec![direct_box("a", 0.0), direct_box("b", 0.5)]);
let overlapping = RaytraceOptions { dielectrics: vec![direct_material(0,"a",DielectricBoundary::ClosedBox),direct_material(1,"b",DielectricBoundary::ClosedBox)], ..Default::default() };
assert!(DirectVisibility::new(&overlap,&overlapping,direct_ray(10.0),4,&mut work,&cancel).is_err());
let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),1,&mut work,&cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work,&cancel) {
Ok(None) => {}
Err(super::super::TraceError::WorkLimit) => { assert!(cursor.step(&mut work,&cancel).is_err());return; }
other => panic!("insufficient crossing budget must refuse: {other:?}"),
}
}
panic!("crossing cap did not stop");
}
#[test]
fn direct_connection_spends_caller_work_and_cancellation_is_terminal_without_result() {
let source = direct_capture(vec![direct_box("glass",0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).unwrap();
let used = work.used();
assert!(used>0);
assert_eq!(cursor.step(&mut work,&cancel).unwrap(),None);
assert_eq!(work.used(),used+1);
work.charge(work.remaining(),&cancel).unwrap();
assert!(matches!(cursor.step(&mut work,&cancel),Err(super::super::TraceError::WorkLimit)));
assert!(cursor.step(&mut work,&cancel).is_err());
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut cursor = DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).unwrap();
let used = work.used();
cancel.cancel();
assert!(matches!(cursor.step(&mut work,&cancel),Err(super::super::TraceError::Cancelled)));
assert_eq!(work.used(),used);
assert!(cursor.step(&mut work,&cancel).is_err());
assert!(matches!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel),Err(super::super::TraceError::Cancelled)));
}
#[test]
fn direct_connection_rejects_hostile_optics_before_any_dark_result() {
let source = direct_capture(vec![direct_box("glass",0.0)]);
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(10000).unwrap();
for value in [f64::NAN, f64::INFINITY, -1.0, 101.0] {
let material = DielectricOptions { absorption_per_mm:[value,0.0,0.0], ..direct_material(0,"glass",DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics:vec![material], ..Default::default() };
assert!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).is_err());
}
let material = DielectricOptions { ior:f64::NAN, ..direct_material(0,"glass",DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics:vec![material], ..Default::default() };
assert!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel).is_err());
}
#[test]
fn direct_connection_accepts_all_eight_disjoint_boxes_and_preserves_reverse_rgb() {
let bodies = (0..8).map(|i| direct_box(&format!("slab{i}"),f64::from(i)*2.0)).collect();
let source = direct_capture(bodies);
let dielectrics = (0..8).map(|i| direct_material(i,&format!("slab{i}"),DielectricBoundary::ClosedBox)).collect();
let options = RaytraceOptions { dielectrics, ..Default::default() };
let expected = Vec3::new(1.0/256.0,1.0/65536.0,1.0);
direct_rgb(direct_complete(&source,&options,direct_ray(20.0)),expected);
let reverse = super::super::Ray::new(Vec3::new(0.25,0.125,18.0),-Vec3::Z,0.0,20.0).unwrap();
direct_rgb(direct_complete(&source,&options,reverse),expected);
let mut too_many = options.clone();
too_many.dielectrics.push(direct_material(8,"ninth",DielectricBoundary::ClosedBox));
let cancel=CancelToken::default();
let mut work=super::super::WorkBudget::new(1000).unwrap();
assert!(DirectVisibility::new(&source,&too_many,direct_ray(20.0),16,&mut work,&cancel).is_err());
}
#[test]
fn direct_connection_box_edge_is_refused_without_a_successful_fallback() {
let source=direct_capture(vec![direct_box("glass",0.0)]);
let options=RaytraceOptions {dielectrics:vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)],..Default::default()};
let ray=super::super::Ray::new(Vec3::new(-10.0,-10.0,-2.0),Vec3::Z,0.0,10.0).unwrap();
let cancel=CancelToken::default();
let mut work=super::super::WorkBudget::new(1000).unwrap();
let mut cursor=DirectVisibility::new(&source,&options,ray,2,&mut work,&cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work,&cancel) {
Ok(None)=>{},
Err(super::super::TraceError::InvalidInput(_))=>return,
other=>panic!("box edge must refuse: {other:?}"),
}
}
panic!("edge ambiguity did not stop");
}
#[test]
fn direct_connection_setup_certification_cannot_escape_caller_work_limit() {
let source=direct_capture(vec![direct_box("glass",0.0)]);
let options=RaytraceOptions {dielectrics:vec![direct_material(0,"glass",DielectricBoundary::ClosedBox)],..Default::default()};
let cancel=CancelToken::default();
let mut work=super::super::WorkBudget::new(1).unwrap();
assert!(matches!(DirectVisibility::new(&source,&options,direct_ray(10.0),2,&mut work,&cancel),Err(super::super::TraceError::WorkLimit)));
assert_eq!(work.used(),1);
}
#[test]
fn direct_oblique_sheet_is_one_certified_plane_with_original_beer_angle() {
let mut body=direct_sheet("oblique",0.0);
for p in &mut Arc::make_mut(&mut body.mesh).positions { p.z += 0.5*p.x; }
let source=direct_capture(vec![body]);
let options=RaytraceOptions {dielectrics:vec![direct_material(0,"oblique",DielectricBoundary::ThinSheet)],..Default::default()};
let ray=super::super::Ray::new(Vec3::new(0.25,0.25,-2.0),Vec3::Z,0.0,10.0).unwrap();
// Plane z=x/2, index matched: thickness/cos(theta)=sqrt(5)/2 mm.
let distance=5.0_f64.sqrt()/2.0;
direct_rgb(direct_complete(&source,&options,ray),Vec3::new(2.0_f64.powf(-distance),4.0_f64.powf(-distance),1.0));
}
@@ -0,0 +1,53 @@
> Preserved unreviewed WIP at Judit's request. NOT APPLIED, NOT COMPILED, NOT RUN. Rebind against the actual receiver; do not overwrite the newer Area/LED tests. This is not an accepted fix or production registration.
# CPU nested certified boxes — unreviewed V1
Status: NOT COMPILED, NOT FORMATTED, NOT RUN. Independent Kernel/source review pending. Not merge-ready or Interactive parity evidence.
Engineer decision: reuse existing JobOptions.nested_media only. No RaytraceOptions flag, saved format, GPU code, third-party code, assets, kernel or SolidCore changes.
## Exact inputs and scope
Receiver: the privately pinned source candidate; current shared source has subsequently received the Area/LED increment.
The private manifest pins raw before/candidate SHA256 for six files, with exact base captures and original CRLF/LF conventions. Shared source was not edited.
render-proposal.patch changes five render files; engine-forwarding-proposal.patch is a separate dependent engine consumer proposal. Both must be reviewed together for command-level support.
verify_packet.py only generates/consumes text diffs and hashes. It does not import/run Rust or product code.
## Three actual routes traced before implementation
1. Final CPU direct: ImageHost.submit_with_options -> Record.options -> WorkTask::new. Existing code retained options in Record but dropped them at WorkTask::new (host.rs698). Proposal forwards JobOptions to ImageTask::new_with_environment_and_nested. Existing public constructors still pass false.
2. Final CPU command: jobs.rs Start had deny_unknown_fields without nested_media; Prepared always returned default options (line701). M2 adds the same existing runtime nested_media input, uses host.admit(scene,request,options) for truthful preflight, and preserves options in Prepared/existing submission. No new flag in RaytraceOptions or saved schema.
3. CPU progressive/Interactive: RaytraceOptions.validate mod.rs895 refuses all dielectrics with progressive, and cpu_request sets progressive. These restrictions remain byte-unchanged. Interactive Snapshot already captures params Value; its Prepared already holds JobOptions and Snapshot::run forwards them at render_interactive.rs228. M2 preserves an accepted existing nested_media value through Start/prepare; no extra snapshot envelope is needed. However Interactive dielectric requests still fail the explicit progressive contract. UI CPU runtime binding policy also currently rejects base.dielectrics. Therefore this packet enables only bounded FINAL CPU nesting, not progressive/Interactive glass. It does not quietly strip optics or fall back.
## Production contract
Every box still passes the existing exact12triangle outward axis-aligned certificate before parent selection. parents() only consumes that certified set. Strict containment is sufficient for this box-only subset, not arbitrary mesh bounds.
At most8 overrides bound hierarchy work to64 comparisons, no allocations. Parent table is fixed [Option<usize>;8] owned by existing OpticalTask. Existing sizeof OpticalTask admission term automatically includes its storage; no field is added to ImageTask's fixed host slot.
For each box, select the smallest strictly containing parent independently of override/body ordering. Partial overlap, equality and touching face/edge/corner refuse. Sheet-volume exclusions, edge/corner ambiguity, clearcoat/other incompatibilities and camera/near-plane-inside refusals remain in place.
A branch stores its existing current medium identity. A fixed immutable parent table is enough for certified strict containment: entry must match the selected box's parent, transmission enters box; matching exit restores parent. Reflection retains current medium. No new mutable stack or global framework is needed.
IORs at each boundary are actual current/parent materials. Existing Fresnel/Snell/radiance eta-squared and current-medium geometric Beer segment evaluation are retained.
Existing MAX_BOUNDARIES8/MAX_PATHS511, cumulative WorkBudget, cancellation and bounded preview tail remain unchanged. This is not arbitrary-depth/lossless exact transport: unresolved boundary continuation retains the existing black-tail policy. Near-coincident representable gaps/spawn exclusion remain a numerical review/qualification obligation; strict f64 inequality alone is not runtime evidence.
Host exception: mask ONLY nested_media to false then compare every other JobOptions field to exact default. Raster+nesting, GPU source transmission/resources, and unsupported CPU option combinations still refuse. No blanket JobOptions bypass.
## Eight appended original tests, all UNRUN
dielectric_tests.rs: original26-test byte prefix23697 preserved, now31 (+5).
- Strict parent order/immediate choice with three certified-box records; nesting remains opt-in.
- Equal, partial and face/edge/corner-touching boxes refuse.
- Actual final PNG uses outer2mm+inner2mm and original Beer oracle (exp(-.8),exp(-1.2),1), independently mapped Reinhard+sRGB -> [151,132,188,255], both body orders.
- Progressive/Interactive dielectric refusal remains.
- Native host preflight rejects defaults/other nondefault controls and raster nesting; actual submit_with_options must complete the same expected image and retain nested flag.
image_tests.rs: original36-test byte prefix65020 preserved, now39 (+3).
- First transmitted path observes four actual boundaries and parent restoration: outerentry .96/2.25; innerentry factor27/49; innerexit factor256/147; finalexit2.16. RGB comparisons1e-12. These factors derive from air1/outer1.5/inner2 normal-incidence Fresnel and radiance eta², not sampled output.
- Actual nested continuation is held to one work unit, then separately cancellation or total WorkLimit must prevent completion.
- Parent allocation remains billed; malformed box and inside camera still refuse.
Existing assertions/numbers are untouched. No test outcome or compile success is claimed.
M2 currently has source-route review, not a new engine integration test; command snapshot regression coverage is an explicit follow-up for integration, never inferred from the direct native-host test.
## Handoff
Main patch SHA256 152d0b043f1998bc18235a868ef5fc6aec82ce2c6c4ef47c3a091e05a46d4bb6.
M2 SHA256 9e5f9fa3a422caa85035aa39088ef68497cc57f0146185b4b0426bc345caab02.
Next: Engineer/Kernel independently review hierarchy, branch identity/IOR/Beer, finite-depth policy, real API compatibility and oracles; coordinate M2 jobs.rs ownership/engine coverage. Only Performance may validate exact admitted source with a fresh grant. No shared application/publication occurred.
@@ -0,0 +1,7 @@
# CPU nested-glass proposals: blocked source work
V1 is BLOCKED: a numerical review found that the normal displaced ray origin can skip an immediately adjacent representable inner boundary. The original proposal remains preserved, not accepted or qualified. Do not apply it as a fix.
The complete engine proposal here replaces the earlier engine-forwarding proposal; never apply both. It exposes the existing nested_media option through the real command preparation and includes two original Session/native-CPU end-to-end tests. Those tests are unexecuted. The render-side V1 blocker still prevents qualification of the combined candidate. A private V2 exact certified-face continuation is undergoing independent review; this archive does not claim that review has passed.
Only certified strictly nested axis-aligned closed boxes are proposed. Arbitrary mesh glass, camera-inside operation, progressive/Interactive glass, and full material-library integration remain unsupported. The original failed-review evidence and all test expectations are preserved. No shared-source adoption of these glass proposals has occurred.
@@ -0,0 +1,160 @@
--- a/crates/engine/src/cmd/render/jobs.rs
+++ b/crates/engine/src/cmd/render/jobs.rs
@@ -11,6 +11,7 @@
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Start {
+ nested_media: bool,
studio_weights: Option<[f64; 3]>,
progressive: bool,
denoise: bool,
@@ -381,6 +382,119 @@
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
assert_eq!(wire["settings"]["width"], 3840);
}
+ #[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
+ fn nested_command_fixture() -> (Session, Value) {
+ let mut s = Session::default();
+ for (name, length, width, height) in [("Outer", 20, 20, 4), ("Inner", 10, 10, 2)] {
+ s.execute("solid.box", &json!({
+ "length":length,"width":width,"height":height,"center":[0,0,2],
+ "operation":"new","body_name":name,
+ })).unwrap();
+ }
+ s.execute("render.scene", &json!({})).unwrap();
+ let scene = crate::product_scene::extract(&s).unwrap();
+ assert_eq!(scene.meshes.len(), 2);
+ let dielectrics: Vec<_> = scene.meshes.iter().enumerate().map(|(index, body)| {
+ assert!(matches!(body.name.as_str(), "Outer" | "Inner"));
+ json!({
+ "body_index":index,"body_name":body.name,"boundary":"closed_box","ior":1.0,
+ "absorption_per_mm":if body.name == "Outer" { [0.1,0.2,0.0] } else { [0.3,0.4,0.0] },
+ "thickness_mm":0.0,
+ })
+ }).collect();
+ let mut camera = solidcraft_render::Camera { distance:50.0, fov:0.0, ..Default::default() };
+ camera.set_view(solidcraft_render::StandardView::Top);
+ camera.target = solidcraft_geom::Vec3::new(0.0,0.0,2.0);
+ let host = solidcraft_render::job::CpuHost::for_platform().unwrap();
+ assert_eq!(host.kind(), solidcraft_render::job::HostKind::NativeWorker);
+ assert!(host.is_available(), "this native test requires an available real CPU worker");
+ s.set_render_job_host(Some(host)).unwrap();
+ let wire = json!({
+ "integrator":"raytrace","nested_media":true,
+ "settings":{"width":1,"height":1,"camera":camera,"background":[255,255,255],"job":null},
+ "raytrace":{"dielectrics":dielectrics,"lights":[],"shadows":false,
+ "environment":{"color":[1,1,1],"intensity":1}},
+ });
+ (s, wire)
+ }
+
+ #[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
+ #[test]
+ fn command_nested_opt_in_reaches_retained_job_options_and_real_beer_transport() {
+ use solidcraft_render::job::{JobId, Phase};
+ let (mut s, wire) = nested_command_fixture();
+ let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty());
+ let document = s.doc.clone();
+ for value in [None, Some(false)] {
+ let mut default = wire.clone();
+ if let Some(value) = value {
+ default["nested_media"] = json!(value);
+ } else {
+ default.as_object_mut().unwrap().remove("nested_media");
+ }
+ for command in ["render.preflight", "render.start"] {
+ let error = s.execute(command, &default).unwrap_err().to_string();
+ assert!(error.contains("nested, touching or overlapping dielectric volumes are unsupported"), "{command}: {error}");
+ }
+ }
+ let checked = s.execute("render.preflight", &wire).unwrap();
+ assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
+ assert_eq!(checked["request"]["raytrace"]["dielectrics"], wire["raytrace"]["dielectrics"]);
+ let started = s.execute("render.start", &wire).unwrap();
+ let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
+ let handle = s.render_job_handle(id).unwrap();
+ let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
+ assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
+ assert_eq!(status.options, solidcraft_render::job::JobOptions { nested_media:true, ..Default::default() });
+ let retained = s.execute("render.status", &json!({"job":id})).unwrap();
+ assert_eq!(retained["options"]["nested_media"], true);
+ let frame = handle.frame().unwrap().unwrap();
+ let image = image::load_from_memory(frame.png()).unwrap().to_rgba8();
+ // Independent Beer oracle: outer2mm + inner2mm, replacing the parent
+ // interval, yields exp(-.8), exp(-1.2),1 -> Reinhard+sRGB [151,132,188].
+ assert_eq!(image.get_pixel(0,0).0, [151,132,188,255]);
+ assert!(std::sync::Arc::ptr_eq(&document, &s.doc));
+ assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
+ }
+
+ #[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
+ #[test]
+ fn command_nesting_keeps_default_false_and_rejects_other_gpu_controls_and_progressive_glass() {
+ use solidcraft_render::job::{JobId, JobOptions, Phase};
+ let (mut s, wire) = nested_command_fixture();
+ for (key, value) in [
+ ("samples", json!(2)), ("max_depth", json!(2)), ("seed", json!(7)),
+ ("denoise", json!(true)), ("lens", json!({})),
+ ] {
+ let mut invalid = wire.clone();
+ invalid[key] = value;
+ for command in ["render.preflight", "render.start"] {
+ assert!(s.execute(command, &invalid).is_err(), "{command} accepted unrelated control {key}");
+ }
+ }
+ let mut progressive = wire.clone();
+ progressive["raytrace"]["progressive"] = json!({"samples_per_pixel":2,"seed":0});
+ let refusal = "InvalidRequest: CPU progressive currently supports isotropic opaque surfaces and directional/point/spot lights; use fixed sampling for dielectric, anisotropic or area/LED inputs";
+ for command in ["render.preflight", "render.start"] {
+ assert_eq!(s.execute(command, &progressive).unwrap_err().to_string(), refusal);
+ }
+ assert_eq!(interactive_params(&wire).unwrap_err().to_string(), refusal);
+
+ // Ordinary CPU requests retain the exact default JobOptions; omission is
+ // not silently promoted to nesting by adding this explicit opt-in.
+ let mut opaque = wire;
+ opaque.as_object_mut().unwrap().remove("nested_media");
+ opaque["raytrace"].as_object_mut().unwrap().remove("dielectrics");
+ s.execute("render.preflight", &opaque).unwrap();
+ let started = s.execute("render.start", &opaque).unwrap();
+ let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
+ let status = s.render_job_handle(id).unwrap().wait_timeout(std::time::Duration::from_secs(60)).unwrap();
+ assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
+ assert_eq!(status.options, JobOptions::default());
+ let retained = s.execute("render.status", &json!({"job":id})).unwrap();
+ assert_eq!(retained["options"]["nested_media"], false);
+ }
+
}
/// Resolve portable intent before choosing an executor. Legacy requests are left intact;
@@ -677,7 +791,8 @@
background: settings.background,
transparent_background: input.transparent_background,
};
- let mut admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
+ let options = solidcraft_render::job::JobOptions { nested_media: input.nested_media, ..Default::default() };
+ let mut admission = host.admit(&scene, &request, options).map_err(job_error)?;
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
let retained_environment =
@@ -698,7 +813,7 @@
Integrator::Raytrace => "opaque_only",
};
let reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":"CPU direct/specular only; no diffuse interreflection", "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":if refraction {vec!["thin_sheet","closed_box"]} else {Vec::<&str>::new()}});
- Ok(Prepared { replace: input.replace, scene, request, reply, environment, executor: resolved.executor, options: Default::default() })
+ Ok(Prepared { replace: input.replace, scene, request, reply, environment, executor: resolved.executor, options })
}
fn prepare_progressive(
--- a/crates/engine/src/cmd/render.rs
+++ b/crates/engine/src/cmd/render.rs
@@ -124,7 +124,7 @@
CommandSpec::new("render.settings.capture_hdr", "Save Render Settings with HDR", resources::capture_settings).icon("settings").doc_only()
.params("name, set: typed CPU/GPU setup patch with hdr/environment unset; path: scoped HDR; expected_sha256?: digest returned by render.hdr.inspect (changed sources refuse); rotation_rad?:0, intensity?:1; samples?:1/4/16/64 for CPU only (default16); assume_linear_srgb?:false for GPU only. Captures immutable bytes and saves resource plus setup atomically in one undo, without a host; returns name/setup/resource metadata, never source bytes"),
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only()
- .params("setup?: saved name OR settings?: full RenderSetup. A typed job selects raster/cpu_raytrace/gpu_path and its numerical options plus embedded HDR before dispatch; only replace and transient studio_weights may accompany it. Without job, the legacy runtime controls below remain available. See docs/render-settings.md. studio_weights?: three finite 0..1 visibility weights for raster or implicit GPU studio sources only; never saved. integrator?: raster (default) or raytrace; progressive?: false (registered GPU path, CPU integrator/LED options are mutually exclusive); samples?: 1..4096; max_depth?: 1..16; seed?: u32 for progressive; raytrace?: bounded runtime progressive/primary_samples/lights/punctual_lights/area_lights/area_samples/seed/exposure_ev/shadows/reflections/anisotropic_faces/emissions/surface_effects/dielectrics/environment options, only with raytrace. CPU raytrace.budget?: compact (legacy default), studio or workstation; see docs/render-cpu-resource-profiles.md for explicit image/geometry/memory/work limits. CPU raytrace.progressive?: {samples_per_pixel:1..profile limit, seed:u32}; requires primary_samples:1 and selected reflections.samples:1; bounded area/LED direct lights retain their existing source and emitter-sample limits; anisotropy and dielectrics require fixed sampling. See docs/render-cpu-progressive.md; environment?: {path: scoped HDR, expected_sha256?: inspected digest, rotation_radians: 0, intensity: 1, samples: 16} OR {resource: saved HDR digest, rotation_radians, intensity, samples}; embedded resources cannot combine with path/hash; explicit sRGB/D65 HDR primaries required. Opaque CPU preview with selected mirror/rough-specular reflections and certified planar anisotropy. HDR with rough reflections or anisotropy requires fully metallic selected bodies and all effective faces; unsupported combinations refuse. No MIS or GI. CPU runtime dielectrics support only certified thin_sheet/closed_box boundaries with explicit IOR and Beer absorption; see docs/render-raytrace-dielectrics.md. CPU emissions are visible radiance only; surface_effects change roughness/shading normals without geometry changes. led_strip?: continuous world-mm LED rectangle recipe; led_samples?: 1/4/16/64 for the whole strip, default16. At most8directional+8punctual+4rectangle/LED sources; shared area N. See docs/render-light-union.md. transparent_background?: false; replace?: false. With progressive:true, raytrace instead accepts GPU dielectrics/clear_coats/textures; environment instead accepts tagged hdr/embedded_hdr/sky with rotation_rad/intensity; file hdr supports expected_sha256, embedded_hdr uses a document resource digest. See docs/render-hdr-resources.md. GPU lens, studio lighting, tone_map, denoise, bounded finishing/backdrop and linear_exr retention are also available. GPU adaptive?: {pilot_samples:16..256,min_samples:16..samples,relative_error:0..1,absolute_error:0.000001..1000000,coverage_error:0.001..0.25} allocates independent production budgets; pilots are discarded, not a certified error guarantee. transmission_samples?:1|2|4 stratifies the first Fresnel branch with extra path cost. volume?:{min_mm:[x,y,z],max_mm:[x,y,z],extinction_per_mm:0..1000,scattering_albedo:[r,g,b],anisotropy:-0.95..0.95} is one finite homogeneous world-mm medium; active media require explicit lighting/environment and refuse glass, denoise and multiple transmission strata. These fields also persist in GPU job.options. See docs/render-materials/TRANSPORT-MASTER-BATCH-2026-10-23.md; see docs/render-transmission.md, render-environment.md, render-runtime-textures.md, render-thin-lens.md, render-studio-lighting.md and render-denoise.md. Optional CPU raytracing requires a raytrace-cpu build and native host, checked before scene capture. Stale geometry is rejected. See docs/render-jobs.md"),
+ .params("setup?: saved name OR settings?: full RenderSetup. A typed job selects raster/cpu_raytrace/gpu_path and its numerical options plus embedded HDR before dispatch; only replace and transient studio_weights may accompany it. Without job, the legacy runtime controls below remain available. See docs/render-settings.md. studio_weights?: three finite 0..1 visibility weights for raster or implicit GPU studio sources only; never saved. integrator?: raster (default) or raytrace; progressive?: false (registered GPU path, CPU integrator/LED options are mutually exclusive); samples?: 1..4096; max_depth?: 1..16; seed?: u32 for progressive; raytrace?: bounded runtime progressive/primary_samples/lights/punctual_lights/area_lights/area_samples/seed/exposure_ev/shadows/reflections/anisotropic_faces/emissions/surface_effects/dielectrics/environment options, only with raytrace. CPU raytrace.budget?: compact (legacy default), studio or workstation; see docs/render-cpu-resource-profiles.md for explicit image/geometry/memory/work limits. CPU raytrace.progressive?: {samples_per_pixel:1..profile limit, seed:u32}; requires primary_samples:1 and selected reflections.samples:1; bounded area/LED direct lights retain their existing source and emitter-sample limits; anisotropy and dielectrics require fixed sampling. See docs/render-cpu-progressive.md; environment?: {path: scoped HDR, expected_sha256?: inspected digest, rotation_radians: 0, intensity: 1, samples: 16} OR {resource: saved HDR digest, rotation_radians, intensity, samples}; embedded resources cannot combine with path/hash; explicit sRGB/D65 HDR primaries required. Opaque CPU preview with selected mirror/rough-specular reflections and certified planar anisotropy. HDR with rough reflections or anisotropy requires fully metallic selected bodies and all effective faces; unsupported combinations refuse. No MIS or GI. CPU runtime dielectrics support only certified thin_sheet/closed_box boundaries with explicit IOR and Beer absorption. nested_media?: false is the existing JobOptions opt-in; fixed CPU images admit only strictly nested certified closed_box boundaries with an exterior camera, while touching/partial overlaps, sheets within volumes and clearcoat combinations refuse. Other nondefault JobOptions remain unsupported on CPU; dielectric progressive and Interactive requests still refuse. GPU progressive uses the same nested_media field with its own certified-media contract; see docs/render-raytrace-dielectrics.md. CPU emissions are visible radiance only; surface_effects change roughness/shading normals without geometry changes. led_strip?: continuous world-mm LED rectangle recipe; led_samples?: 1/4/16/64 for the whole strip, default16. At most8directional+8punctual+4rectangle/LED sources; shared area N. See docs/render-light-union.md. transparent_background?: false; replace?: false. With progressive:true, raytrace instead accepts GPU dielectrics/clear_coats/textures; environment instead accepts tagged hdr/embedded_hdr/sky with rotation_rad/intensity; file hdr supports expected_sha256, embedded_hdr uses a document resource digest. See docs/render-hdr-resources.md. GPU lens, studio lighting, tone_map, denoise, bounded finishing/backdrop and linear_exr retention are also available. GPU adaptive?: {pilot_samples:16..256,min_samples:16..samples,relative_error:0..1,absolute_error:0.000001..1000000,coverage_error:0.001..0.25} allocates independent production budgets; pilots are discarded, not a certified error guarantee. transmission_samples?:1|2|4 stratifies the first Fresnel branch with extra path cost. volume?:{min_mm:[x,y,z],max_mm:[x,y,z],extinction_per_mm:0..1000,scattering_albedo:[r,g,b],anisotropy:-0.95..0.95} is one finite homogeneous world-mm medium; active media require explicit lighting/environment and refuse glass, denoise and multiple transmission strata. These fields also persist in GPU job.options. See docs/render-materials/TRANSPORT-MASTER-BATCH-2026-10-23.md; see docs/render-transmission.md, render-environment.md, render-runtime-textures.md, render-thin-lens.md, render-studio-lighting.md and render-denoise.md. Optional CPU raytracing requires a raytrace-cpu build and native host, checked before scene capture. Stale geometry is rejected. See docs/render-jobs.md"),
CommandSpec::new("render.start", "Render Image", jobs::start).key("R").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()
@@ -0,0 +1,29 @@
--- a/crates/engine/src/cmd/render/jobs.rs
+++ b/crates/engine/src/cmd/render/jobs.rs
@@ -11,6 +11,7 @@
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Start {
+ nested_media: bool,
studio_weights: Option<[f64; 3]>,
progressive: bool,
denoise: bool,
@@ -677,7 +678,8 @@
background: settings.background,
transparent_background: input.transparent_background,
};
- let mut admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
+ let options = solidcraft_render::job::JobOptions { nested_media: input.nested_media, ..Default::default() };
+ let mut admission = host.admit(&scene, &request, options).map_err(job_error)?;
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
let retained_environment =
@@ -698,7 +700,7 @@
Integrator::Raytrace => "opaque_only",
};
let reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":"CPU direct/specular only; no diffuse interreflection", "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":if refraction {vec!["thin_sheet","closed_box"]} else {Vec::<&str>::new()}});
- Ok(Prepared { replace: input.replace, scene, request, reply, environment, executor: resolved.executor, options: Default::default() })
+ Ok(Prepared { replace: input.replace, scene, request, reply, environment, executor: resolved.executor, options })
}
fn prepare_progressive(
@@ -0,0 +1,487 @@
--- a/crates/render/src/raytrace/dielectric.rs
+++ b/crates/render/src/raytrace/dielectric.rs
@@ -241,6 +241,52 @@
lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z
}
+/// Immediate parents of already-certified axis-aligned boxes. Strict containment
+/// is sufficient for this exact box subset; touching and partial overlap refuse.
+/// Eight overrides bound this to at most 64 comparisons, without allocation.
+pub(super) fn parents(
+ certificates: &[Option<Certificate>; MAX_OVERRIDES],
+ nested: bool,
+) -> Result<[Option<usize>; MAX_OVERRIDES]> {
+ let mut result = [None; MAX_OVERRIDES];
+ for (child, certificate) in certificates.iter().enumerate() {
+ let Some(Certificate::ClosedBox(bounds)) = certificate else { continue };
+ for (candidate, certificate) in certificates.iter().enumerate() {
+ let Some(Certificate::ClosedBox(container)) = certificate else { continue };
+ if candidate == child || !overlaps(*bounds, *container) {
+ continue;
+ }
+ if !nested {
+ return Err(TraceError::InvalidInput("nested, touching or overlapping dielectric volumes are unsupported"));
+ }
+ if strictly_contains(*container, *bounds) {
+ let parent = result.get_mut(child).ok_or(TraceError::Internal("dielectric child index"))?;
+ let replace = match *parent {
+ None => true,
+ Some(previous) => match certificates.get(previous).copied().flatten() {
+ Some(Certificate::ClosedBox(old)) => strictly_contains(old, *container),
+ _ => return Err(TraceError::Internal("dielectric parent certificate")),
+ },
+ };
+ if replace {
+ *parent = Some(candidate);
+ }
+ } else if !strictly_contains(*bounds, *container) {
+ return Err(TraceError::InvalidInput("dielectric boxes must be disjoint or strictly nested; touching and overlap are unsupported"));
+ }
+ }
+ }
+ Ok(result)
+}
+
+fn strictly_contains(outer: Bounds, inner: Bounds) -> bool {
+ let lo = outer.min();
+ let hi = outer.max();
+ let a = inner.min();
+ let b = inner.max();
+ lo.x < a.x && lo.y < a.y && lo.z < a.z && hi.x > b.x && hi.y > b.y && hi.z > b.z
+}
+
#[cfg(test)]
#[path = "dielectric_tests.rs"]
pub(super) mod tests;
--- a/crates/render/src/raytrace/image.rs
+++ b/crates/render/src/raytrace/image.rs
@@ -80,22 +80,16 @@
}
type Look = PreparedPrimitive;
-fn certificates(scene: &ProductScene, options: &RaytraceOptions) -> job::Result<[Option<Certificate>; MAX_OVERRIDES]> {
+fn certificates(
+ scene: &ProductScene,
+ options: &RaytraceOptions,
+ nested: bool,
+) -> job::Result<([Option<Certificate>; MAX_OVERRIDES], [Option<usize>; MAX_OVERRIDES])> {
let mut result = [None; MAX_OVERRIDES];
for (slot, input) in result.iter_mut().zip(&options.dielectrics) {
*slot = Some(dielectric::certify(scene, input).map_err(error)?);
}
- for (i, left) in result.iter().enumerate() {
- if let Some(Certificate::ClosedBox(a)) = left {
- for right in result.iter().skip(i + 1) {
- if let Some(Certificate::ClosedBox(b)) = right
- && dielectric::overlaps(*a, *b)
- {
- return Err(invalid("nested, touching or overlapping dielectric volumes are unsupported"));
- }
- }
- }
- }
+ let parents = dielectric::parents(&result, nested).map_err(error)?;
// Sheets within volumes are unsupported. Conservatively use the admitted
// tiny sheet's bounds, rather than pretending to resolve medium nesting.
for (i, certificate) in result.iter().enumerate() {
@@ -112,10 +106,19 @@
}
}
}
- Ok(result)
+ Ok((result, parents))
}
pub fn admit(scene: &ProductScene, request: &ImageRequest, options: &RaytraceOptions) -> job::Result<Admission> {
+ admit_with_nested(scene, request, options, false)
+}
+
+pub(crate) fn admit_with_nested(
+ scene: &ProductScene,
+ request: &ImageRequest,
+ options: &RaytraceOptions,
+ nested: bool,
+) -> job::Result<Admission> {
options.validate()?;
if let Some(original) = &request.raytrace {
original.validate()?;
@@ -161,7 +164,7 @@
}
}
anisotropic_faces::prepare(scene, options, || Ok(()))?;
- certificates(scene, options)?;
+ certificates(scene, options, nested)?;
for input in &options.dielectrics {
if options.reflections.as_ref().is_some_and(|r| r.bodies.iter().any(|v| v.body == input.body_name))
|| options.anisotropic_faces.iter().flatten().any(|v| v.body_index == input.body_index)
@@ -376,6 +379,7 @@
/// lighting state, encode pixels, or allocate outside the existing job reservation.
struct OpticalTask {
certificates: [Option<Certificate>; MAX_OVERRIDES],
+ parents: [Option<usize>; MAX_OVERRIDES],
pending: Option<DielectricPixel>,
}
@@ -515,16 +519,27 @@
options: RaytraceOptions,
input: Option<crate::lighting::environment_input::EnvironmentInput>,
) -> job::Result<Self> {
+ Self::new_with_environment_and_nested(scene, request, cancel, options, input, false)
+ }
+
+ pub(crate) fn new_with_environment_and_nested(
+ scene: ProductScene,
+ request: ImageRequest,
+ cancel: CancelToken,
+ options: RaytraceOptions,
+ input: Option<crate::lighting::environment_input::EnvironmentInput>,
+ nested: bool,
+ ) -> job::Result<Self> {
cancel.check()?;
- let mut admission = admit(&scene, &request, &options)?;
+ let mut admission = admit_with_nested(&scene, &request, &options, nested)?;
let mut optical = Vec::new();
if !options.dielectrics.is_empty() {
- let certificates = certificates(&scene, &options)?;
+ let (certificates, parents) = certificates(&scene, &options, nested)?;
optical.try_reserve_exact(1).map_err(|_| error(TraceError::AllocationFailed))?;
if optical.capacity() != 1 {
return Err(error(TraceError::MemoryLimit));
}
- optical.push(OpticalTask { certificates, pending: None });
+ optical.push(OpticalTask { certificates, parents, pending: None });
}
if input.is_some() {
options.validate_environment_reflections(&scene)?;
@@ -1363,16 +1378,21 @@
if boundary_axes > 1 {
return Err(invalid("dielectric box edge or corner has an ambiguous interface normal"));
}
+ let parent = optical.parents.get(index).copied().ok_or_else(|| invalid("missing dielectric parent"))?;
+ let parent_ior = match parent {
+ Some(parent) => self.options.dielectrics.get(parent).ok_or_else(|| invalid("missing parent optical material"))?.ior,
+ None => 1.0,
+ };
let (incident, transmitted, next_medium) = if shared.front_face {
- if state.path.medium.is_some() {
- return Err(invalid("nested dielectric entry"));
+ if state.path.medium != parent {
+ return Err(invalid("dielectric entry does not match certified parent"));
}
- (1.0, medium.ior, Some(index))
+ (parent_ior, medium.ior, Some(index))
} else {
if state.path.medium != Some(index) {
return Err(invalid("dielectric exit without matching entry"));
}
- (medium.ior, 1.0, None)
+ (medium.ior, parent_ior, parent)
};
let optics = dielectric::interface(direction, normal, incident, transmitted).map_err(error)?;
let f = optics.reflectance;
--- a/crates/render/src/job/host.rs
+++ b/crates/render/src/job/host.rs
@@ -624,15 +624,16 @@
request: ImageRequest,
cancel: CancelToken,
environment: Option<crate::lighting::environment_input::EnvironmentInput>,
+ job_options: JobOptions,
) -> Result<Self> {
- let _ = &environment;
+ let _ = (&environment, &job_options);
match request.integrator {
Integrator::Raster => ImageTask::new(scene, request, cancel).and_then(TaskSlot::new).map(Self::Raster),
Integrator::Raytrace => {
#[cfg(feature = "raytrace-cpu")]
{
let options = request.raytrace.clone().unwrap_or_default();
- crate::raytrace::image::ImageTask::new_with_environment(scene, request, cancel, options, environment)
+ crate::raytrace::image::ImageTask::new_with_environment_and_nested(scene, request, cancel, options, environment, job_options.nested_media)
.and_then(TaskSlot::new)
.map(Self::Raytrace)
}
@@ -695,7 +696,13 @@
let request = work.request.ok_or_else(|| JobError::new(ErrorCode::Internal, "missing registered executor request"))?;
executor.begin(work.scene, request, work.handle.0.options, work.handle.0.cancel.clone()).map(WorkTask::Registered)
} else {
- WorkTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone(), work.environment)
+ WorkTask::new(
+ work.scene,
+ work.handle.0.request.clone(),
+ work.handle.0.cancel.clone(),
+ work.environment,
+ work.handle.0.options,
+ )
}
}))
.unwrap_or_else(|_| Err(JobError::new(ErrorCode::Internal, "image executor failed while preparing the job")));
@@ -1104,10 +1111,27 @@
Ok(admission)
} else {
request.cpu_budget().validate_execution()?;
- if options != JobOptions::default() {
+ // Only the existing nesting opt-in is admitted on the CPU raytrace path.
+ // Every other JobOptions field retains its exact default-only contract.
+ if (JobOptions { nested_media: false, ..options }) != JobOptions::default() {
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU images use raytrace options, not progressive sampling controls"));
}
- admit(scene, request)
+ if !options.nested_media {
+ return admit(scene, request);
+ }
+ if request.integrator != Integrator::Raytrace || request.environment.is_some() || request.textures.is_some() || scene.has_transmission() {
+ return Err(JobError::new(ErrorCode::InvalidRequest, "CPU nested media require explicit raytrace dielectric overrides"));
+ }
+ #[cfg(feature = "raytrace-cpu")]
+ {
+ let defaults = super::RaytraceOptions::default();
+ let raytrace = request.raytrace.as_ref().unwrap_or(&defaults);
+ crate::raytrace::image::admit_with_nested(scene, request, raytrace, true)
+ }
+ #[cfg(not(feature = "raytrace-cpu"))]
+ {
+ Err(JobError::new(ErrorCode::HostUnavailable, "CPU raytracing is not compiled in; enable raytrace-cpu"))
+ }
}
}
--- a/crates/render/src/raytrace/dielectric_tests.rs
+++ b/crates/render/src/raytrace/dielectric_tests.rs
@@ -514,3 +514,131 @@
// maps to 98, so this also detects a consumer still using the old series.
assert_eq!(pixel.expect("Brewster sheet fixture must finish"), [95, 95, 95, 255]);
}
+
+#[test]
+fn strict_box_parent_selection_is_order_independent_and_opt_in() {
+ let bounds = |lo: f64, hi: f64| Bounds::new(Vec3::new(lo, lo, lo), Vec3::new(hi, hi, hi)).unwrap();
+ let mut certificates = [None; MAX_OVERRIDES];
+ certificates[0] = Some(Certificate::ClosedBox(bounds(2.0, 3.0)));
+ certificates[1] = Some(Certificate::ClosedBox(bounds(0.0, 5.0)));
+ certificates[2] = Some(Certificate::ClosedBox(bounds(1.0, 4.0)));
+ assert!(parents(&certificates, false).is_err());
+ assert_eq!(parents(&certificates, true).unwrap(), [Some(2), None, Some(1), None, None, None, None, None]);
+ certificates.swap(0, 1);
+ assert_eq!(parents(&certificates, true).unwrap(), [None, Some(2), Some(0), None, None, None, None, None]);
+}
+
+#[test]
+fn nesting_opt_in_still_refuses_touching_equal_and_partially_overlapping_boxes() {
+ let outer = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
+ for (lo, hi) in [
+ (Vec3::new(0.0, 1.0, 1.0), Vec3::new(3.0, 3.0, 3.0)),
+ (Vec3::new(4.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
+ (Vec3::new(4.0, 4.0, 1.0), Vec3::new(5.0, 5.0, 3.0)),
+ (Vec3::new(4.0, 4.0, 4.0), Vec3::new(5.0, 5.0, 5.0)),
+ (Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)),
+ (Vec3::new(3.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
+ ] {
+ let mut certificates = [None; MAX_OVERRIDES];
+ certificates[0] = Some(Certificate::ClosedBox(outer));
+ certificates[1] = Some(Certificate::ClosedBox(Bounds::new(lo, hi).unwrap()));
+ assert!(parents(&certificates, true).is_err());
+ }
+}
+
+pub(in crate::raytrace) fn nested_fixture(reverse: bool) -> (ProductScene, RaytraceOptions) {
+ let mut outer = box_body(4.0);
+ outer.name = "Outer".into();
+ let mut inner = box_body(2.0);
+ inner.name = "Inner".into();
+ for p in &mut Arc::make_mut(&mut inner.mesh).positions {
+ *p = Vec3::new(p.x * 0.5, p.y * 0.5, p.z + 1.0);
+ }
+ let mut scene = scene(outer);
+ scene.meshes.push(inner);
+ if reverse {
+ scene.meshes.reverse();
+ }
+ let dielectrics = scene.meshes.iter().enumerate().map(|(body_index, body)| DielectricOptions {
+ body_index,
+ body_name: body.name.clone(),
+ boundary: DielectricBoundary::ClosedBox,
+ ior: 1.0,
+ absorption_per_mm: if body.name == "Outer" { [0.1, 0.2, 0.0] } else { [0.3, 0.4, 0.0] },
+ thickness_mm: 0.0,
+ }).collect();
+ (scene, RaytraceOptions {
+ dielectrics,
+ environment: environment(),
+ lights: Some(Vec::new()),
+ shadows: false,
+ ..Default::default()
+ })
+}
+
+#[test]
+fn actual_nested_png_uses_parent_only_outside_the_inner_interval() {
+ for reverse in [false, true] {
+ let (scene, options) = nested_fixture(reverse);
+ assert!(super::super::image::admit(&scene, &request(options.clone()), &options).is_err());
+ let mut task = super::super::image::ImageTask::new_with_environment_and_nested(
+ scene, request(options.clone()), CancelToken::default(), options, None, true,
+ ).unwrap();
+ let mut completed = false;
+ for _ in 0..100_000 {
+ if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
+ let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
+ // Outer length2, inner length2; Beer radiance (exp(-.8), exp(-1.2), 1).
+ // Reinhard then IEC sRGB encoding gives these independently fixed bytes.
+ assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
+ completed = true;
+ break;
+ }
+ }
+ assert!(completed, "nested final image did not complete");
+ }
+}
+
+#[test]
+fn nested_opt_in_does_not_enable_dielectric_progressive_or_interactive() {
+ let (_, mut options) = nested_fixture(false);
+ options.progressive = Some(Default::default());
+ assert!(options.validate().is_err());
+ let (_, options) = nested_fixture(false);
+ assert!(crate::job::interactive::cpu_request(&request(options)).is_err());
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn native_host_forwards_only_the_existing_nested_media_exception() {
+ use crate::job::{CpuHost, ErrorCode, JobId, JobOptions, Phase};
+ let host = CpuHost::for_platform().unwrap();
+ let (scene, options) = nested_fixture(false);
+ let request = request(options);
+ let nested = JobOptions { nested_media: true, ..Default::default() };
+ assert!(host.admit(&scene, &request, JobOptions::default()).is_err());
+ assert!(host.admit(&scene, &request, nested).is_ok());
+ for bad in [
+ JobOptions { samples: 2, ..nested },
+ JobOptions { max_depth: 2, ..nested },
+ JobOptions { seed: 7, ..nested },
+ JobOptions { exposure_ev: 1.0, ..nested },
+ JobOptions { transmission_samples: 2, ..nested },
+ JobOptions { denoise: true, ..nested },
+ JobOptions { linear_exr: true, ..nested },
+ ] {
+ assert_eq!(host.admit(&scene, &request, bad).unwrap_err().code, ErrorCode::InvalidRequest);
+ }
+ let mut raster = request.clone();
+ raster.integrator = Integrator::Raster;
+ raster.raytrace = None;
+ assert!(host.admit(&scene, &raster, nested).is_err());
+ let handle = host.submit_with_options(
+ JobId { session: 6302, serial: 1, generation: 1 }, 1, scene, request, nested,
+ ).unwrap();
+ assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
+ assert_eq!(handle.status().unwrap().options.nested_media, true);
+ let frame = handle.frame().unwrap().unwrap();
+ let image = ::image::load_from_memory(frame.png()).unwrap().to_rgba8();
+ assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
+}
--- a/crates/render/src/raytrace/image_tests.rs
+++ b/crates/render/src/raytrace/image_tests.rs
@@ -1421,3 +1421,103 @@
cancel.cancel();
assert_eq!(PendingPixelSlot::new(&cancel).err().unwrap().code, ErrorCode::Cancelled);
}
+
+fn nested_optical_task() -> ImageTask {
+ let (scene, mut options) = dielectric::tests::nested_fixture(false);
+ for medium in &mut options.dielectrics {
+ medium.ior = if medium.body_name == "Outer" { 1.5 } else { 2.0 };
+ medium.absorption_per_mm = [0.0; 3];
+ }
+ ImageTask::new_with_environment_and_nested(
+ scene, dielectric::tests::request(options.clone()), CancelToken::default(), options, None, true,
+ ).unwrap()
+}
+
+#[test]
+fn nested_image_interfaces_use_actual_parent_ior_and_restore_it_on_exit() {
+ let mut task = nested_optical_task();
+ let outer_entry = 0.96 / 2.25;
+ let inner_entry = outer_entry * (27.0 / 49.0);
+ let inner_exit = inner_entry * (256.0 / 147.0);
+ let expected = [
+ (1, Some(0), outer_entry),
+ (2, Some(1), inner_entry),
+ (3, Some(0), inner_exit),
+ (4, None, inner_exit * 2.16),
+ ];
+ let mut next = 0;
+ for _ in 0..100_000 {
+ assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
+ if let Some(state) = task.optical.first().and_then(|optical| optical.pending.as_ref()) {
+ let (depth, medium, throughput) = expected[next];
+ if state.path.boundaries == depth && state.path.medium == medium {
+ assert!((state.path.throughput.x - throughput).abs() < 1e-12);
+ assert!((state.path.throughput.y - throughput).abs() < 1e-12);
+ assert!((state.path.throughput.z - throughput).abs() < 1e-12);
+ next += 1;
+ if next == expected.len() {
+ return;
+ }
+ }
+ }
+ }
+ panic!("nested first transmitted path did not cross all four interfaces");
+}
+
+#[test]
+fn nested_paths_keep_one_unit_work_cancellation_and_total_budget_contracts() {
+ for cancel in [false, true] {
+ let mut task = nested_optical_task();
+ let mut observed = false;
+ for _ in 0..100_000 {
+ let before = task.work.used();
+ assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
+ assert!(task.work.used() - before <= 1, "one-unit continuation overspent its quantum");
+ if task.optical.first().and_then(|optical| optical.pending.as_ref()).is_some_and(|state| state.path.medium == Some(1)) {
+ if cancel {
+ task.cancel.cancel();
+ } else {
+ task.work.limit = task.work.used();
+ }
+ assert_eq!(task.step(1).err().unwrap().code, if cancel { ErrorCode::Cancelled } else { ErrorCode::LimitExceeded });
+ assert!(!task.done);
+ observed = true;
+ break;
+ }
+ }
+ assert!(observed, "nested interior was never reached");
+ }
+}
+
+#[test]
+fn nested_admission_bills_parent_table_and_keeps_geometry_and_camera_refusals() {
+ let (scene, options) = dielectric::tests::nested_fixture(false);
+ let request = dielectric::tests::request(options.clone());
+ let admitted = admit_with_nested(&scene, &request, &options, true).unwrap();
+ let original = options.retained_dielectric_bytes().unwrap()
+ + request.raytrace.as_ref().unwrap().retained_dielectric_bytes().unwrap()
+ + std::mem::size_of::<OpticalTask>() as u64;
+ assert!(admitted.preparation_bytes >= original);
+ assert!(std::mem::size_of::<OpticalTask>() >= std::mem::size_of::<[Option<usize>; MAX_OVERRIDES]>());
+
+ let mut invalid = scene.clone();
+ std::sync::Arc::make_mut(&mut invalid.meshes[1].mesh).triangles.pop();
+ assert!(admit_with_nested(&invalid, &request, &options, true).is_err());
+
+ let mut inside = request.clone();
+ inside.camera.distance = 0.1;
+ inside.camera.target = Vec3::new(0.0, 0.0, 2.0);
+ let mut task = ImageTask::new_with_environment_and_nested(scene, inside, CancelToken::default(), options, None, true).unwrap();
+ for _ in 0..100_000 {
+ match task.step(1) {
+ Err(error) => {
+ assert_eq!(error.code, ErrorCode::InvalidScene);
+ assert!(error.message.contains("inside a dielectric"));
+ return;
+ }
+ Ok(TaskPoll::Pending(_)) => {}
+ Ok(TaskPoll::Complete(_)) => panic!("inside camera must not publish"),
+ }
+ }
+ panic!("inside camera was not refused");
+}
@@ -0,0 +1,5 @@
# Current-source cumulative V2 plus engine proposal
The current-rebind.patch is a single seven-path cumulative proposal against the pinned C12 receiver, replacing separate V2/engine applications; do not double-apply. It preserves current Area/LED, all213 UI declarations and all prior Render test prefixes. Only one NEW engine-test diagnostic literal is updated for the actual Area/LED contract; no historical assertion or optical oracle changes.
Independent numerical delta and Engine route reviews found no new actionable source blocker. It proposes13 Render and2 Engine declarations. All remain uncompiled/unrun and the proposal is NOT APPLIED. The shared C12 source remains frozen. Recheck exact preimages and dependencies before any later adoption. Existing V1 remains blocked and archived; general/progressive/Interactive glass and normal physical-library routing remain open.
@@ -0,0 +1,11 @@
# CPU nested certified boxes V2: reviewed source checkpoint
NOT APPLIED, COMPILED OR RUN. Independent numerical/source review found no new actionable blocker within the exact certified-box contract. This is not runtime closure, complete glass support or production readiness. V1 remains blocked; its original adjacent one-ULP-boundary counterexample is preserved.
The complete patch targets its original captured base. The delta targets V1. Use one form against its exact base, never both. Current shared source has subsequently received Area/LED and formatter changes; a new private rebinding is in progress. Do not overlay these files on the current checkout. The engine command proposal remains separate and was not included in this numerical review.
Only nested_media=true and a certified axis-aligned closed-box face use an exact physical face point. It replaces only the constant face-axis coordinate, keeps tangential coordinates unchanged and strictly interior, and refuses edges/corners, simultaneous-face ties and wrong/tangent outgoing directions. That same point is used before Beer attenuation and for the continuation origin/interval start. Defaultfalse, global spawn, opaque/shadow paths and ThinSheet behavior remain unchanged. No arbitrary separation epsilon or tolerance relaxation is introduced.
All original and V1 test bytes remain prefixes. Five additional original cases exercise actual one-ULP interfaces from both sides, all-axis own-zero exclusion, reflected child Beer/parent restoration, explicit ambiguity refusals and preserved default launch. All thirteen combined new Render declarations are unexecuted.
Still open: actual compiler/native tests, real command/host integration, arbitrary mesh glass, camera-inside operation, deeper-path quality, progressive/Interactive glass and physical material-library routing. Existing eight-boundary and path/work limits remain; completion alone does not establish optical convergence or general glass parity.
@@ -0,0 +1,861 @@
--- a/crates/render/src/raytrace/dielectric.rs
+++ b/crates/render/src/raytrace/dielectric.rs
@@ -241,6 +241,160 @@
lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z
}
+/// Immediate parents of already-certified axis-aligned boxes. Strict containment
+/// is sufficient for this exact box subset; touching and partial overlap refuse.
+/// Eight overrides bound this to at most 64 comparisons, without allocation.
+pub(super) fn parents(
+ certificates: &[Option<Certificate>; MAX_OVERRIDES],
+ nested: bool,
+) -> Result<[Option<usize>; MAX_OVERRIDES]> {
+ let mut result = [None; MAX_OVERRIDES];
+ for (child, certificate) in certificates.iter().enumerate() {
+ let Some(Certificate::ClosedBox(bounds)) = certificate else { continue };
+ for (candidate, certificate) in certificates.iter().enumerate() {
+ let Some(Certificate::ClosedBox(container)) = certificate else { continue };
+ if candidate == child || !overlaps(*bounds, *container) {
+ continue;
+ }
+ if !nested {
+ return Err(TraceError::InvalidInput("nested, touching or overlapping dielectric volumes are unsupported"));
+ }
+ if strictly_contains(*container, *bounds) {
+ let parent = result.get_mut(child).ok_or(TraceError::Internal("dielectric child index"))?;
+ let replace = match *parent {
+ None => true,
+ Some(previous) => match certificates.get(previous).copied().flatten() {
+ Some(Certificate::ClosedBox(old)) => strictly_contains(old, *container),
+ _ => return Err(TraceError::Internal("dielectric parent certificate")),
+ },
+ };
+ if replace {
+ *parent = Some(candidate);
+ }
+ } else if !strictly_contains(*bounds, *container) {
+ return Err(TraceError::InvalidInput("dielectric boxes must be disjoint or strictly nested; touching and overlap are unsupported"));
+ }
+ }
+ }
+ Ok(result)
+}
+
+fn strictly_contains(outer: Bounds, inner: Bounds) -> bool {
+ let lo = outer.min();
+ let hi = outer.max();
+ let a = inner.min();
+ let b = inner.max();
+ lo.x < a.x && lo.y < a.y && lo.z < a.z && hi.x > b.x && hi.y > b.y && hi.z > b.z
+}
+
+/// A certified axis-aligned face point, shared by propagation and Beer intervals.
+/// This is used only by the explicit nested-box path, never by general spawn.
+#[derive(Clone, Copy)]
+pub(super) struct ExactBoxFace {
+ pub point: Vec3,
+ axis: usize,
+ outward: f64,
+ entering: bool,
+}
+
+impl ExactBoxFace {
+ pub fn spawn(self, direction: Vec3, reflected: bool) -> Result<super::Ray> {
+ let ray = super::Ray::new(self.point, direction, 0.0_f64.next_up(), super::MAX_DISTANCE_MM)?;
+ let side = super::component(ray.direction(), self.axis) * self.outward;
+ let exterior = if reflected { self.entering } else { !self.entering };
+ if (exterior && side <= 0.0) || (!exterior && side >= 0.0) {
+ return Err(TraceError::InvalidInput("nested box continuation is tangent or on the wrong side"));
+ }
+ Ok(ray)
+ }
+}
+
+/// Recover the exact constant-axis coordinate from the actual certified face.
+/// Tangential coordinates are validated, never clamped or displaced. Equal
+/// ray/other-face depths conservatively refuse independently of ordinal ties.
+pub(super) fn exact_box_face(
+ bounds: Bounds,
+ vertices: [Vec3; 3],
+ normal: Vec3,
+ position: Vec3,
+ incoming: super::Ray,
+) -> Result<ExactBoxFace> {
+ let mut face = None;
+ for axis in 0..3 {
+ let [a, b, c] = vertices.map(|v| super::component(v, axis));
+ if a == b && b == c {
+ if face.is_some() {
+ return Err(TraceError::InvalidInput("nested box face has ambiguous constant axes"));
+ }
+ face = Some((axis, a));
+ }
+ }
+ let (axis, plane) = face.ok_or(TraceError::InvalidInput("nested box face is not axis aligned"))?;
+ let lo = bounds.min();
+ let hi = bounds.max();
+ let outward = if plane == super::component(lo, axis) {
+ -1.0
+ } else if plane == super::component(hi, axis) {
+ 1.0
+ } else {
+ return Err(TraceError::InvalidInput("nested box face disagrees with its certificate"));
+ };
+ let axis_vector = match axis {
+ 0 => Vec3::X,
+ 1 => Vec3::Y,
+ 2 => Vec3::Z,
+ _ => return Err(TraceError::Internal("nested box face axis")),
+ };
+ if normal != axis_vector * outward || Triangle::new(vertices)?.geometric_normal() != Some(normal) {
+ return Err(TraceError::InvalidInput("nested box outward normal disagrees with its certified face"));
+ }
+ for vertex in vertices {
+ for a in 0..3 {
+ let coordinate = super::component(vertex, a);
+ if coordinate != super::component(lo, a) && coordinate != super::component(hi, a) {
+ return Err(TraceError::InvalidInput("nested box face vertex is not a certified corner"));
+ }
+ }
+ }
+ let point = match axis {
+ 0 => Vec3::new(plane, position.y, position.z),
+ 1 => Vec3::new(position.x, plane, position.z),
+ 2 => Vec3::new(position.x, position.y, plane),
+ _ => return Err(TraceError::Internal("nested box point axis")),
+ };
+ if !point.is_finite() {
+ return Err(TraceError::InvalidInput("nested box reconstructed point is not finite"));
+ }
+ let direction = super::component(incoming.direction(), axis);
+ if direction == 0.0 {
+ return Err(TraceError::InvalidInput("nested box incident ray is tangent"));
+ }
+ let depth = (plane - super::component(incoming.origin(), axis)) / direction;
+ let [min, max] = incoming.interval();
+ if !depth.is_finite() || depth < min || depth > max {
+ return Err(TraceError::InvalidInput("nested box face is outside the incident ray interval"));
+ }
+ for a in 0..3 {
+ if a == axis {
+ continue;
+ }
+ let coordinate = super::component(point, a);
+ if coordinate <= super::component(lo, a) || coordinate >= super::component(hi, a) {
+ return Err(TraceError::InvalidInput("nested box edge, corner or outside-face point is ambiguous"));
+ }
+ let d = super::component(incoming.direction(), a);
+ if d != 0.0 {
+ for plane in [super::component(lo, a), super::component(hi, a)] {
+ let other = (plane - super::component(incoming.origin(), a)) / d;
+ if other == depth {
+ return Err(TraceError::InvalidInput("nested box simultaneous face depths are ambiguous"));
+ }
+ }
+ }
+ }
+ Ok(ExactBoxFace { point, axis, outward, entering: direction * outward < 0.0 })
+}
+
#[cfg(test)]
#[path = "dielectric_tests.rs"]
pub(super) mod tests;
--- a/crates/render/src/raytrace/image.rs
+++ b/crates/render/src/raytrace/image.rs
@@ -80,22 +80,16 @@
}
type Look = PreparedPrimitive;
-fn certificates(scene: &ProductScene, options: &RaytraceOptions) -> job::Result<[Option<Certificate>; MAX_OVERRIDES]> {
+fn certificates(
+ scene: &ProductScene,
+ options: &RaytraceOptions,
+ nested: bool,
+) -> job::Result<([Option<Certificate>; MAX_OVERRIDES], [Option<usize>; MAX_OVERRIDES])> {
let mut result = [None; MAX_OVERRIDES];
for (slot, input) in result.iter_mut().zip(&options.dielectrics) {
*slot = Some(dielectric::certify(scene, input).map_err(error)?);
}
- for (i, left) in result.iter().enumerate() {
- if let Some(Certificate::ClosedBox(a)) = left {
- for right in result.iter().skip(i + 1) {
- if let Some(Certificate::ClosedBox(b)) = right
- && dielectric::overlaps(*a, *b)
- {
- return Err(invalid("nested, touching or overlapping dielectric volumes are unsupported"));
- }
- }
- }
- }
+ let parents = dielectric::parents(&result, nested).map_err(error)?;
// Sheets within volumes are unsupported. Conservatively use the admitted
// tiny sheet's bounds, rather than pretending to resolve medium nesting.
for (i, certificate) in result.iter().enumerate() {
@@ -112,10 +106,19 @@
}
}
}
- Ok(result)
+ Ok((result, parents))
}
pub fn admit(scene: &ProductScene, request: &ImageRequest, options: &RaytraceOptions) -> job::Result<Admission> {
+ admit_with_nested(scene, request, options, false)
+}
+
+pub(crate) fn admit_with_nested(
+ scene: &ProductScene,
+ request: &ImageRequest,
+ options: &RaytraceOptions,
+ nested: bool,
+) -> job::Result<Admission> {
options.validate()?;
if let Some(original) = &request.raytrace {
original.validate()?;
@@ -161,7 +164,7 @@
}
}
anisotropic_faces::prepare(scene, options, || Ok(()))?;
- certificates(scene, options)?;
+ certificates(scene, options, nested)?;
for input in &options.dielectrics {
if options.reflections.as_ref().is_some_and(|r| r.bodies.iter().any(|v| v.body == input.body_name))
|| options.anisotropic_faces.iter().flatten().any(|v| v.body_index == input.body_index)
@@ -376,6 +379,8 @@
/// lighting state, encode pixels, or allocate outside the existing job reservation.
struct OpticalTask {
certificates: [Option<Certificate>; MAX_OVERRIDES],
+ parents: [Option<usize>; MAX_OVERRIDES],
+ nested: bool,
pending: Option<DielectricPixel>,
}
@@ -515,16 +520,27 @@
options: RaytraceOptions,
input: Option<crate::lighting::environment_input::EnvironmentInput>,
) -> job::Result<Self> {
+ Self::new_with_environment_and_nested(scene, request, cancel, options, input, false)
+ }
+
+ pub(crate) fn new_with_environment_and_nested(
+ scene: ProductScene,
+ request: ImageRequest,
+ cancel: CancelToken,
+ options: RaytraceOptions,
+ input: Option<crate::lighting::environment_input::EnvironmentInput>,
+ nested: bool,
+ ) -> job::Result<Self> {
cancel.check()?;
- let mut admission = admit(&scene, &request, &options)?;
+ let mut admission = admit_with_nested(&scene, &request, &options, nested)?;
let mut optical = Vec::new();
if !options.dielectrics.is_empty() {
- let certificates = certificates(&scene, &options)?;
+ let (certificates, parents) = certificates(&scene, &options, nested)?;
optical.try_reserve_exact(1).map_err(|_| error(TraceError::AllocationFailed))?;
if optical.capacity() != 1 {
return Err(error(TraceError::MemoryLimit));
}
- optical.push(OpticalTask { certificates, pending: None });
+ optical.push(OpticalTask { certificates, parents, nested, pending: None });
}
if input.is_some() {
options.validate_environment_reflections(&scene)?;
@@ -1299,7 +1315,19 @@
let scene = PreparedScene { bvh, primitives: &self.looks, source: &self.scene };
// Optics always uses the certified geometric boundary, before optional opaque
// shading-normal effects. Distance starts before the numerical spawn offset.
- let shared = scene.resolve_hit(pending.ray, hit).map_err(error)?;
+ let mut shared = scene.resolve_hit(pending.ray, hit).map_err(error)?;
+ let exact_face = if optical.nested {
+ match state.selected.and_then(|index| optical.certificates.get(index).copied().flatten()) {
+ Some(Certificate::ClosedBox(bounds)) => {
+ let face = dielectric::exact_box_face(bounds, shared.vertices, shared.geometric_normal, shared.position, pending.ray).map_err(error)?;
+ shared.position = face.point;
+ Some(face)
+ }
+ _ => None,
+ }
+ } else {
+ None
+ };
if let Some(index) = state.path.medium {
let medium = self.options.dielectrics.get(index).ok_or_else(|| invalid("active dielectric medium"))?;
let distance = shared.position.dist(state.path.segment_start);
@@ -1363,16 +1391,21 @@
if boundary_axes > 1 {
return Err(invalid("dielectric box edge or corner has an ambiguous interface normal"));
}
+ let parent = optical.parents.get(index).copied().ok_or_else(|| invalid("missing dielectric parent"))?;
+ let parent_ior = match parent {
+ Some(parent) => self.options.dielectrics.get(parent).ok_or_else(|| invalid("missing parent optical material"))?.ior,
+ None => 1.0,
+ };
let (incident, transmitted, next_medium) = if shared.front_face {
- if state.path.medium.is_some() {
- return Err(invalid("nested dielectric entry"));
+ if state.path.medium != parent {
+ return Err(invalid("dielectric entry does not match certified parent"));
}
- (1.0, medium.ior, Some(index))
+ (parent_ior, medium.ior, Some(index))
} else {
if state.path.medium != Some(index) {
return Err(invalid("dielectric exit without matching entry"));
}
- (medium.ior, 1.0, None)
+ (medium.ior, parent_ior, parent)
};
let optics = dielectric::interface(direction, normal, incident, transmitted).map_err(error)?;
let f = optics.reflectance;
@@ -1380,8 +1413,14 @@
(optics, Vec3::new(f, f, f), Vec3::new(t, t, t), next_medium)
}
};
+ let spawn = |direction, reflected| {
+ match exact_face {
+ Some(face) => face.spawn(direction, reflected).map_err(error),
+ None => shared.spawn(direction).map_err(error),
+ }
+ };
state.push(OpticalPath {
- ray: shared.spawn(optics.reflected).map_err(error)?,
+ ray: spawn(optics.reflected, true)?,
throughput: dielectric::multiply(state.path.throughput, reflected_weight),
medium: state.path.medium,
segment_start: shared.position,
@@ -1390,7 +1429,7 @@
})?;
if let Some(direction) = optics.transmitted {
state.push(OpticalPath {
- ray: shared.spawn(direction).map_err(error)?,
+ ray: spawn(direction, false)?,
throughput: dielectric::multiply(state.path.throughput, transmitted_weight),
medium: next_medium,
segment_start: shared.position,
--- a/crates/render/src/job/host.rs
+++ b/crates/render/src/job/host.rs
@@ -624,15 +624,16 @@
request: ImageRequest,
cancel: CancelToken,
environment: Option<crate::lighting::environment_input::EnvironmentInput>,
+ job_options: JobOptions,
) -> Result<Self> {
- let _ = &environment;
+ let _ = (&environment, &job_options);
match request.integrator {
Integrator::Raster => ImageTask::new(scene, request, cancel).and_then(TaskSlot::new).map(Self::Raster),
Integrator::Raytrace => {
#[cfg(feature = "raytrace-cpu")]
{
let options = request.raytrace.clone().unwrap_or_default();
- crate::raytrace::image::ImageTask::new_with_environment(scene, request, cancel, options, environment)
+ crate::raytrace::image::ImageTask::new_with_environment_and_nested(scene, request, cancel, options, environment, job_options.nested_media)
.and_then(TaskSlot::new)
.map(Self::Raytrace)
}
@@ -695,7 +696,13 @@
let request = work.request.ok_or_else(|| JobError::new(ErrorCode::Internal, "missing registered executor request"))?;
executor.begin(work.scene, request, work.handle.0.options, work.handle.0.cancel.clone()).map(WorkTask::Registered)
} else {
- WorkTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone(), work.environment)
+ WorkTask::new(
+ work.scene,
+ work.handle.0.request.clone(),
+ work.handle.0.cancel.clone(),
+ work.environment,
+ work.handle.0.options,
+ )
}
}))
.unwrap_or_else(|_| Err(JobError::new(ErrorCode::Internal, "image executor failed while preparing the job")));
@@ -1104,10 +1111,27 @@
Ok(admission)
} else {
request.cpu_budget().validate_execution()?;
- if options != JobOptions::default() {
+ // Only the existing nesting opt-in is admitted on the CPU raytrace path.
+ // Every other JobOptions field retains its exact default-only contract.
+ if (JobOptions { nested_media: false, ..options }) != JobOptions::default() {
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU images use raytrace options, not progressive sampling controls"));
}
- admit(scene, request)
+ if !options.nested_media {
+ return admit(scene, request);
+ }
+ if request.integrator != Integrator::Raytrace || request.environment.is_some() || request.textures.is_some() || scene.has_transmission() {
+ return Err(JobError::new(ErrorCode::InvalidRequest, "CPU nested media require explicit raytrace dielectric overrides"));
+ }
+ #[cfg(feature = "raytrace-cpu")]
+ {
+ let defaults = super::RaytraceOptions::default();
+ let raytrace = request.raytrace.as_ref().unwrap_or(&defaults);
+ crate::raytrace::image::admit_with_nested(scene, request, raytrace, true)
+ }
+ #[cfg(not(feature = "raytrace-cpu"))]
+ {
+ Err(JobError::new(ErrorCode::HostUnavailable, "CPU raytracing is not compiled in; enable raytrace-cpu"))
+ }
}
}
--- a/crates/render/src/raytrace/dielectric_tests.rs
+++ b/crates/render/src/raytrace/dielectric_tests.rs
@@ -514,3 +514,212 @@
// maps to 98, so this also detects a consumer still using the old series.
assert_eq!(pixel.expect("Brewster sheet fixture must finish"), [95, 95, 95, 255]);
}
+
+#[test]
+fn strict_box_parent_selection_is_order_independent_and_opt_in() {
+ let bounds = |lo: f64, hi: f64| Bounds::new(Vec3::new(lo, lo, lo), Vec3::new(hi, hi, hi)).unwrap();
+ let mut certificates = [None; MAX_OVERRIDES];
+ certificates[0] = Some(Certificate::ClosedBox(bounds(2.0, 3.0)));
+ certificates[1] = Some(Certificate::ClosedBox(bounds(0.0, 5.0)));
+ certificates[2] = Some(Certificate::ClosedBox(bounds(1.0, 4.0)));
+ assert!(parents(&certificates, false).is_err());
+ assert_eq!(parents(&certificates, true).unwrap(), [Some(2), None, Some(1), None, None, None, None, None]);
+ certificates.swap(0, 1);
+ assert_eq!(parents(&certificates, true).unwrap(), [None, Some(2), Some(0), None, None, None, None, None]);
+}
+
+#[test]
+fn nesting_opt_in_still_refuses_touching_equal_and_partially_overlapping_boxes() {
+ let outer = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
+ for (lo, hi) in [
+ (Vec3::new(0.0, 1.0, 1.0), Vec3::new(3.0, 3.0, 3.0)),
+ (Vec3::new(4.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
+ (Vec3::new(4.0, 4.0, 1.0), Vec3::new(5.0, 5.0, 3.0)),
+ (Vec3::new(4.0, 4.0, 4.0), Vec3::new(5.0, 5.0, 5.0)),
+ (Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)),
+ (Vec3::new(3.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
+ ] {
+ let mut certificates = [None; MAX_OVERRIDES];
+ certificates[0] = Some(Certificate::ClosedBox(outer));
+ certificates[1] = Some(Certificate::ClosedBox(Bounds::new(lo, hi).unwrap()));
+ assert!(parents(&certificates, true).is_err());
+ }
+}
+
+pub(in crate::raytrace) fn nested_fixture(reverse: bool) -> (ProductScene, RaytraceOptions) {
+ let mut outer = box_body(4.0);
+ outer.name = "Outer".into();
+ let mut inner = box_body(2.0);
+ inner.name = "Inner".into();
+ for p in &mut Arc::make_mut(&mut inner.mesh).positions {
+ *p = Vec3::new(p.x * 0.5, p.y * 0.5, p.z + 1.0);
+ }
+ let mut scene = scene(outer);
+ scene.meshes.push(inner);
+ if reverse {
+ scene.meshes.reverse();
+ }
+ let dielectrics = scene.meshes.iter().enumerate().map(|(body_index, body)| DielectricOptions {
+ body_index,
+ body_name: body.name.clone(),
+ boundary: DielectricBoundary::ClosedBox,
+ ior: 1.0,
+ absorption_per_mm: if body.name == "Outer" { [0.1, 0.2, 0.0] } else { [0.3, 0.4, 0.0] },
+ thickness_mm: 0.0,
+ }).collect();
+ (scene, RaytraceOptions {
+ dielectrics,
+ environment: environment(),
+ lights: Some(Vec::new()),
+ shadows: false,
+ ..Default::default()
+ })
+}
+
+#[test]
+fn actual_nested_png_uses_parent_only_outside_the_inner_interval() {
+ for reverse in [false, true] {
+ let (scene, options) = nested_fixture(reverse);
+ assert!(super::super::image::admit(&scene, &request(options.clone()), &options).is_err());
+ let mut task = super::super::image::ImageTask::new_with_environment_and_nested(
+ scene, request(options.clone()), CancelToken::default(), options, None, true,
+ ).unwrap();
+ let mut completed = false;
+ for _ in 0..100_000 {
+ if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
+ let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
+ // Outer length2, inner length2; Beer radiance (exp(-.8), exp(-1.2), 1).
+ // Reinhard then IEC sRGB encoding gives these independently fixed bytes.
+ assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
+ completed = true;
+ break;
+ }
+ }
+ assert!(completed, "nested final image did not complete");
+ }
+}
+
+#[test]
+fn nested_opt_in_does_not_enable_dielectric_progressive_or_interactive() {
+ let (_, mut options) = nested_fixture(false);
+ options.progressive = Some(Default::default());
+ assert!(options.validate().is_err());
+ let (_, options) = nested_fixture(false);
+ assert!(crate::job::interactive::cpu_request(&request(options)).is_err());
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn native_host_forwards_only_the_existing_nested_media_exception() {
+ use crate::job::{CpuHost, ErrorCode, JobId, JobOptions, Phase};
+ let host = CpuHost::for_platform().unwrap();
+ let (scene, options) = nested_fixture(false);
+ let request = request(options);
+ let nested = JobOptions { nested_media: true, ..Default::default() };
+ assert!(host.admit(&scene, &request, JobOptions::default()).is_err());
+ assert!(host.admit(&scene, &request, nested).is_ok());
+ for bad in [
+ JobOptions { samples: 2, ..nested },
+ JobOptions { max_depth: 2, ..nested },
+ JobOptions { seed: 7, ..nested },
+ JobOptions { exposure_ev: 1.0, ..nested },
+ JobOptions { transmission_samples: 2, ..nested },
+ JobOptions { denoise: true, ..nested },
+ JobOptions { linear_exr: true, ..nested },
+ ] {
+ assert_eq!(host.admit(&scene, &request, bad).unwrap_err().code, ErrorCode::InvalidRequest);
+ }
+ let mut raster = request.clone();
+ raster.integrator = Integrator::Raster;
+ raster.raytrace = None;
+ assert!(host.admit(&scene, &raster, nested).is_err());
+ let handle = host.submit_with_options(
+ JobId { session: 6302, serial: 1, generation: 1 }, 1, scene, request, nested,
+ ).unwrap();
+ assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
+ assert_eq!(handle.status().unwrap().options.nested_media, true);
+ let frame = handle.frame().unwrap().unwrap();
+ let image = ::image::load_from_memory(frame.png()).unwrap().to_rgba8();
+ assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
+}
+
+fn face_fixture(axis: usize, positive: bool) -> (Bounds, [Vec3; 3], Vec3, Vec3) {
+ let point = |a: f64, u: f64, v: f64| match axis {
+ 0 => Vec3::new(a, u, v),
+ 1 => Vec3::new(v, a, u),
+ 2 => Vec3::new(u, v, a),
+ _ => panic!("test axis"),
+ };
+ let bounds = Bounds::new(Vec3::new(1.0, 1.0, 1.0), Vec3::new(4.0, 4.0, 4.0)).unwrap();
+ let plane = if positive { 4.0 } else { 1.0 };
+ let mut vertices = [point(plane, 1.0, 1.0), point(plane, 4.0, 1.0), point(plane, 4.0, 4.0)];
+ if !positive {
+ vertices.swap(1, 2);
+ }
+ let normal = match axis { 0 => Vec3::X, 1 => Vec3::Y, 2 => Vec3::Z, _ => panic!("test axis") } * if positive { 1.0 } else { -1.0 };
+ (bounds, vertices, normal, point(plane, 2.5, 2.5))
+}
+
+#[test]
+fn exact_face_excludes_own_zero_depth_and_retains_one_ulp_neighbour_on_every_axis_and_side() {
+ use super::super::Ray;
+ for axis in 0..3 {
+ for positive in [false, true] {
+ let (bounds, vertices, normal, position) = face_fixture(axis, positive);
+ for entering in [false, true] {
+ let direction = if entering { -normal } else { normal };
+ let incoming = Ray::new(position - direction, direction, 0.0, 10.0).unwrap();
+ let face = exact_box_face(bounds, vertices, normal, position, incoming).unwrap();
+ assert_eq!(face.point, position, "face diagonal is valid interior");
+ let optics = interface(direction, -direction, if entering { 1.0 } else { 1.5 }, if entering { 1.5 } else { 1.0 }).unwrap();
+ let reflected = face.spawn(optics.reflected, true).unwrap();
+ let transmitted = face.spawn(optics.transmitted.unwrap(), false).unwrap();
+ let triangle = Triangle::new(vertices).unwrap();
+ assert!(triangle.intersect(&reflected).is_none(), "own reflected face at t0 must be excluded");
+ assert!(triangle.intersect(&transmitted).is_none(), "own transmitted face at t0 must be excluded");
+ assert_eq!(reflected.origin(), face.point);
+ assert_eq!(transmitted.origin(), face.point);
+ assert_eq!(transmitted.interval()[0], 0.0_f64.next_up());
+ let plane = super::super::component(position, axis);
+ let next = if super::super::component(direction, axis) > 0.0 { plane.next_up() } else { plane.next_down() };
+ let neighbour = vertices.map(|v| match axis {
+ 0 => Vec3::new(next, v.y, v.z),
+ 1 => Vec3::new(v.x, next, v.z),
+ 2 => Vec3::new(v.x, v.y, next),
+ _ => panic!("test axis"),
+ });
+ let hit = Triangle::new(neighbour).unwrap().intersect(&transmitted).expect("one-ULP positive face must remain ahead");
+ assert_eq!(hit.distance, (next - plane).abs());
+ }
+ }
+ }
+}
+
+#[test]
+fn exact_face_recovers_certified_plane_without_clamping_or_blessing_ties() {
+ use super::super::Ray;
+ let (bounds, vertices, normal, point) = face_fixture(2, false);
+ let incoming = Ray::new(point - Vec3::Z, Vec3::Z, 0.0, 10.0).unwrap();
+ let rounded = Vec3::new(point.x, point.y, point.z.next_up());
+ let face = exact_box_face(bounds, vertices, normal, rounded, incoming).unwrap();
+ assert_eq!(face.point, point);
+ for bad in [
+ Vec3::new(1.0, 2.5, 1.0), Vec3::new(1.0, 1.0, 1.0),
+ Vec3::new(1.0_f64.next_down(), 2.5, 1.0), Vec3::new(f64::NAN, 2.5, 1.0),
+ ] {
+ assert!(exact_box_face(bounds, vertices, normal, bad, incoming).is_err());
+ }
+ let tied = Ray::new(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0), 0.0, 10.0).unwrap();
+ // Even if rounded tangential reconstruction claims interior, independently
+ // equal actual ray/face depths cannot inherit the BVH ordinal's blessing.
+ let uncertain = Vec3::new(1.0_f64.next_up(), 1.0_f64.next_up(), 1.0);
+ assert!(exact_box_face(bounds, vertices, normal, uncertain, tied).is_err());
+ assert!(exact_box_face(bounds, vertices, -normal, point, incoming).is_err());
+ let other_bounds = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
+ assert!(exact_box_face(other_bounds, vertices, normal, point, incoming).is_err());
+ let tangent = Ray::new(point - Vec3::X, Vec3::X, 0.0, 10.0).unwrap();
+ assert!(exact_box_face(bounds, vertices, normal, point, tangent).is_err());
+ assert!(face.spawn(Vec3::X, true).is_err());
+ assert!(face.spawn(-Vec3::Z, false).is_err());
+ assert!(face.spawn(Vec3::Z, true).is_err());
+}
--- a/crates/render/src/raytrace/image_tests.rs
+++ b/crates/render/src/raytrace/image_tests.rs
@@ -1421,3 +1421,241 @@
cancel.cancel();
assert_eq!(PendingPixelSlot::new(&cancel).err().unwrap().code, ErrorCode::Cancelled);
}
+
+fn nested_optical_task() -> ImageTask {
+ let (scene, mut options) = dielectric::tests::nested_fixture(false);
+ for medium in &mut options.dielectrics {
+ medium.ior = if medium.body_name == "Outer" { 1.5 } else { 2.0 };
+ medium.absorption_per_mm = [0.0; 3];
+ }
+ ImageTask::new_with_environment_and_nested(
+ scene, dielectric::tests::request(options.clone()), CancelToken::default(), options, None, true,
+ ).unwrap()
+}
+
+#[test]
+fn nested_image_interfaces_use_actual_parent_ior_and_restore_it_on_exit() {
+ let mut task = nested_optical_task();
+ let outer_entry = 0.96 / 2.25;
+ let inner_entry = outer_entry * (27.0 / 49.0);
+ let inner_exit = inner_entry * (256.0 / 147.0);
+ let expected = [
+ (1, Some(0), outer_entry),
+ (2, Some(1), inner_entry),
+ (3, Some(0), inner_exit),
+ (4, None, inner_exit * 2.16),
+ ];
+ let mut next = 0;
+ for _ in 0..100_000 {
+ assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
+ if let Some(state) = task.optical.first().and_then(|optical| optical.pending.as_ref()) {
+ let (depth, medium, throughput) = expected[next];
+ if state.path.boundaries == depth && state.path.medium == medium {
+ assert!((state.path.throughput.x - throughput).abs() < 1e-12);
+ assert!((state.path.throughput.y - throughput).abs() < 1e-12);
+ assert!((state.path.throughput.z - throughput).abs() < 1e-12);
+ next += 1;
+ if next == expected.len() {
+ return;
+ }
+ }
+ }
+ }
+ panic!("nested first transmitted path did not cross all four interfaces");
+}
+
+#[test]
+fn nested_paths_keep_one_unit_work_cancellation_and_total_budget_contracts() {
+ for cancel in [false, true] {
+ let mut task = nested_optical_task();
+ let mut observed = false;
+ for _ in 0..100_000 {
+ let before = task.work.used();
+ assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
+ assert!(task.work.used() - before <= 1, "one-unit continuation overspent its quantum");
+ if task.optical.first().and_then(|optical| optical.pending.as_ref()).is_some_and(|state| state.path.medium == Some(1)) {
+ if cancel {
+ task.cancel.cancel();
+ } else {
+ task.work.limit = task.work.used();
+ }
+ assert_eq!(task.step(1).err().unwrap().code, if cancel { ErrorCode::Cancelled } else { ErrorCode::LimitExceeded });
+ assert!(!task.done);
+ observed = true;
+ break;
+ }
+ }
+ assert!(observed, "nested interior was never reached");
+ }
+}
+
+#[test]
+fn nested_admission_bills_parent_table_and_keeps_geometry_and_camera_refusals() {
+ let (scene, options) = dielectric::tests::nested_fixture(false);
+ let request = dielectric::tests::request(options.clone());
+ let admitted = admit_with_nested(&scene, &request, &options, true).unwrap();
+ let original = options.retained_dielectric_bytes().unwrap()
+ + request.raytrace.as_ref().unwrap().retained_dielectric_bytes().unwrap()
+ + std::mem::size_of::<OpticalTask>() as u64;
+ assert!(admitted.preparation_bytes >= original);
+ assert!(std::mem::size_of::<OpticalTask>() >= std::mem::size_of::<[Option<usize>; MAX_OVERRIDES]>());
+
+ let mut invalid = scene.clone();
+ std::sync::Arc::make_mut(&mut invalid.meshes[1].mesh).triangles.pop();
+ assert!(admit_with_nested(&invalid, &request, &options, true).is_err());
+
+ let mut inside = request.clone();
+ inside.camera.distance = 0.1;
+ inside.camera.target = Vec3::new(0.0, 0.0, 2.0);
+ let mut task = ImageTask::new_with_environment_and_nested(scene, inside, CancelToken::default(), options, None, true).unwrap();
+ for _ in 0..100_000 {
+ match task.step(1) {
+ Err(error) => {
+ assert_eq!(error.code, ErrorCode::InvalidScene);
+ assert!(error.message.contains("inside a dielectric"));
+ return;
+ }
+ Ok(TaskPoll::Pending(_)) => {}
+ Ok(TaskPoll::Complete(_)) => panic!("inside camera must not publish"),
+ }
+ }
+ panic!("inside camera was not refused");
+}
+
+fn close_gap_nested_task(gap: f64, positive: bool) -> ImageTask {
+ use std::sync::Arc;
+ let mut outer = dielectric::tests::box_body(3.0);
+ outer.name = "OuterGap".into();
+ for p in &mut Arc::make_mut(&mut outer.mesh).positions {
+ *p = Vec3::new(p.x * 0.2, p.y * 0.2, p.z + 1.0);
+ }
+ let mut inner = dielectric::tests::box_body(2.0);
+ inner.name = "InnerGap".into();
+ for p in &mut Arc::make_mut(&mut inner.mesh).positions {
+ *p = Vec3::new(p.x * 0.1, p.y * 0.1, if p.z == 0.0 { 1.0 + gap } else { 3.0 });
+ }
+ let mut scene = dielectric::tests::scene(outer);
+ scene.meshes.push(inner);
+ let options = RaytraceOptions {
+ dielectrics: scene.meshes.iter().enumerate().map(|(body_index, body)| job::DielectricOptions {
+ body_index, body_name:body.name.clone(), boundary:job::DielectricBoundary::ClosedBox,
+ ior:1.0, absorption_per_mm:[0.0;3], thickness_mm:0.0,
+ }).collect(),
+ lights:Some(Vec::new()), shadows:false,
+ environment:Some(job::RaytraceEnvironmentOptions { color:[1.0;3], intensity:1.0 }),
+ ..Default::default()
+ };
+ let mut request = dielectric::tests::request(options.clone());
+ request.camera.set_view(if positive { crate::StandardView::Bottom } else { crate::StandardView::Top });
+ request.camera.target = Vec3::new(0.0,0.0,2.5);
+ request.camera.fov = 0.0;
+ ImageTask::new_with_environment_and_nested(scene, request, CancelToken::default(), options, None, true).unwrap()
+}
+
+#[test]
+fn positive_one_ulp_nested_gap_and_separated_control_visit_every_real_interface() {
+ for gap in [f64::EPSILON, 0.25] {
+ for positive in [false, true] {
+ let mut task = close_gap_nested_task(gap, positive);
+ let points = if positive { [1.0,1.0+gap,3.0,4.0] } else { [4.0,3.0,1.0+gap,1.0] };
+ let media = [Some(0),Some(1),Some(0),None];
+ let mut next = 0;
+ let mut completed = false;
+ for _ in 0..100_000 {
+ match task.step(1).unwrap() {
+ TaskPoll::Pending(_) => {
+ if next < points.len()
+ && let Some(state) = task.optical.first().and_then(|optical| optical.pending.as_ref())
+ && state.path.boundaries == next as u8 + 1
+ {
+ assert_eq!(state.path.medium, media[next]);
+ assert_eq!(state.path.segment_start.z, points[next]);
+ assert_eq!(state.path.ray.origin(), state.path.segment_start, "propagation and Beer start share one physical point");
+ assert_eq!(state.path.throughput, Vec3::new(1.0,1.0,1.0));
+ next += 1;
+ }
+ }
+ TaskPoll::Complete(png) => {
+ assert_eq!(next, 4, "an admitted interface was skipped");
+ let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
+ assert_eq!(image.get_pixel(0,0).0, [188,188,188,255]);
+ completed = true;
+ break;
+ }
+ }
+ }
+ assert!(completed, "close-gap image failed to complete");
+ }
+ }
+}
+
+#[test]
+fn nested_reflected_route_keeps_child_beer_then_restores_parent_from_both_sides() {
+ for positive in [false, true] {
+ let (scene, mut options) = dielectric::tests::nested_fixture(false);
+ for medium in &mut options.dielectrics {
+ medium.ior = if medium.body_name == "Outer" { 1.5 } else { 2.0 };
+ }
+ let mut request = dielectric::tests::request(options.clone());
+ request.camera.set_view(if positive { crate::StandardView::Bottom } else { crate::StandardView::Top });
+ request.camera.target = Vec3::new(0.0,0.0,2.0);
+ request.camera.fov = 0.0;
+ let mut task = ImageTask::new_with_environment_and_nested(scene, request, CancelToken::default(), options, None, true).unwrap();
+ let (near, far) = if positive { (1.0,3.0) } else { (3.0,1.0) };
+ let factor = (0.96 / 2.25) * (27.0 / 49.0) / 49.0;
+ let reflected = Vec3::new(factor * (-0.1_f64 - 0.3*2.0).exp(), factor * (-0.2_f64 - 0.4*2.0).exp(), factor);
+ let returned = dielectric::multiply(reflected, Vec3::new((-0.3_f64*2.0).exp(),(-0.4_f64*2.0).exp(),1.0)) * (256.0/147.0);
+ let mut saw_reflected = false;
+ let mut restored = false;
+ for _ in 0..1_000_000 {
+ if let TaskPoll::Complete(_) = task.step(1).unwrap() {
+ break;
+ }
+ let Some(state) = task.optical.first().and_then(|optical| optical.pending.as_ref()) else { continue };
+ let path = state.path;
+ if path.boundaries == 3 && path.medium == Some(1) && path.segment_start.z == far {
+ assert!((path.throughput - reflected).len() < 1e-12);
+ assert_eq!(path.ray.origin(), path.segment_start);
+ assert!(path.ray.direction().z * if positive { -1.0 } else { 1.0 } > 0.0);
+ saw_reflected = true;
+ }
+ if saw_reflected && path.boundaries == 4 && path.medium == Some(0) && path.segment_start.z == near {
+ assert!((path.throughput - returned).len() < 1e-12);
+ assert_eq!(path.ray.origin(), path.segment_start);
+ restored = true;
+ break;
+ }
+ }
+ assert!(saw_reflected && restored, "actual reflected child route never restored its parent");
+ }
+}
+
+#[test]
+fn exact_box_launch_is_opt_in_and_legacy_default_origin_is_preserved() {
+ for nested in [false, true] {
+ let mut material = dielectric::tests::input(job::DielectricBoundary::ClosedBox);
+ material.ior = 1.0;
+ let options = RaytraceOptions { dielectrics:vec![material], lights:Some(Vec::new()), shadows:false, ..Default::default() };
+ let request = dielectric::tests::request(options.clone());
+ let mut task = ImageTask::new_with_environment_and_nested(
+ dielectric::tests::scene(dielectric::tests::box_body(2.0)), request, CancelToken::default(), options, None, nested,
+ ).unwrap();
+ let mut observed = false;
+ for _ in 0..100_000 {
+ assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
+ if let Some(state) = task.optical.first().and_then(|optical| optical.pending.as_ref())
+ && state.path.boundaries == 1 && state.path.medium == Some(0)
+ {
+ assert_eq!(state.path.segment_start.z, 2.0);
+ if nested {
+ assert_eq!(state.path.ray.origin(), state.path.segment_start);
+ } else {
+ assert!(state.path.ray.origin().z < state.path.segment_start.z, "default must retain its original inward offset");
+ }
+ observed = true;
+ break;
+ }
+ }
+ assert!(observed, "first box entry was not observed");
+ }
+}
@@ -0,0 +1,43 @@
# CPU nested boxes V2 — independent source review
Disposition: no new actionable source or mathematical blocker found within the accepted ClosedBox-only contract. V1's specific F1 launch-offset mechanism is closed by V2 at source/proof level. This is NOT a compile/test PASS, runtime closure, integration approval, full optical parity or merge readiness. V1 remains blocked and immutable. No Cargo, formatter/compiler, numerical probe, product/test execution, shared edit, Git or GitHub action was performed.
Bound inputs: README5ff008f3c3e1dd6e8febdc9a86ff511a2cf8c863e60c3ac2d9300aeb5c761c0f; manifest95c20c8ff74a0cf0b963c11d5b257ceeac34ebd7d83d934851aa6cb6341e95ad; complete patch1110cb5d84144c396e8d1842f3e716c260efd3691b9a23f2e8b689eb611ac70f; V1 delta1283979d6fd95431a6afff5db21546fba000516cd0bbe88226149d2eaa2be950. Contract reviewed:6b41fcce5dfff48197affd100551cff2a82224dc2327d5b34224ca8cf0a314bf. source-receipt.json records independent hashes, raw prefixes and exact reconstruction of both patch forms by an independent text consumer. Use one form against its own exact base, never both.
## Contract implementation
Candidate dielectric.rs:293-398 supplies ExactBoxFace/exact_box_face. It recovers exactly one constant vertex axis, matches that plane to the selected certified min/max, requires the outward axis normal to agree with the actual triangle normal, and verifies original vertex corner membership. This consumes already-certified twelve-triangle boxes; it does not turn arbitrary bounds into a certificate.
Only the face-axis position coordinate is replaced. Tangential coordinates stay unchanged, finite and strictly within the face. Boundary equality and outside-face reconstruction refuse rather than clamp. Internal triangulation diagonals remain admitted. The incoming axis component must be nonzero and its plane depth must be finite inside the original interval. Equal independently computed depths to another box face refuse regardless of a rounded interior reconstruction or BVH ordinal tie. This covers the contract's explicit representable tie policy, not every real-arithmetic near-corner classification.
ExactBoxFace::spawn constructs the actual normalized Ray at that same point with least-positive min and then checks its actual face-axis sign. For entering=true, reflection requires the exterior sign and transmission the interior sign; for entering=false (an exit), those sides reverse. The boolean logic implements those four cases correctly. Tangent/wrong-side continuations refuse. Total internal reflection continues to have no transmitted branch.
Image integration at image.rs:1319-1333 is guarded by OpticalTask.nested AND the selected Certificate::ClosedBox. shared.position is corrected before current-medium attenuation. Both branch segment_start fields and their new Ray.origin use that same corrected point (1426,1435). Reflection retains the old current medium; transmission and parent restoration/IOR are unchanged from reviewed V1. No new public API or mutable medium stack is introduced.
The F1 proof now applies directly: current own-face plane numerator is c-c=0 and rejected by positive min; Inner's distinct one-ULP plane remains a positive numerator rather than lying behind a shifted origin. No arbitrary separation epsilon, normal-plane predicate, next-up origin displacement or expectation relaxation was added.
## Preserved scope and bounds
With nested_media=false, exact-face recovery is bypassed and both branches call the existing shared.spawn with the unmodified reconstructed point. Legacy constructors still pass false. Host code is byte-identical to V1. ThinSheet and unselected opaque hits cannot acquire an ExactBoxFace. prepared.rs/global spawn, shadow launch, GPU and the progressive dielectric refusal are outside this delta.
The extra boolean lives in the existing OpticalTask allocation; the checked size_of::<OpticalTask>() admission term automatically accounts for its actual layout. Constant three-axis/three-vertex checks add bounded work within one charged interface continuation. Existing selector/traversal cumulative work/cancel checks, maximum eight boundaries,511 paths and fixed pending stack remain. No measured latency, allocation or cancellation outcome is asserted.
The primary camera/near-plane-outside guard remains. Eight-boundary black tail still limits deep paths, including five nested volumes requiring ten direct crossings. Tangential reconstruction, near-corner intersection robustness and arbitrary meshes remain outside the certified launch/depth claim; V2 does not certify them by accepting a positive gap.
## Original test proposals
Independent raw-byte checks confirm complete original and V1 prefixes: dielectric tests26→31→33; image tests36→39→42. Five V2 tests are appended without modifying previous assertions. All thirteen combined V1/V2 added tests are UNCOMPILED and UNRUN.
- Own-zero exclusion/one-ULP neighbor exercises all three axes, both outward signs and incoming entry/exit. It verifies actual Triangle queries and actual normalized spawned rays; this helper fixture does not substitute for the whole task test.
- Exact-plane recovery explicitly accepts a face diagonal and rejects edge/corner/outside/NaN tangential points, bad certificate/normal, incident tangent, outgoing tangent/wrong sign and independently equal incoming face depths.
- The actual F1 task checks four visited media/interfaces and exact physical z endpoints, from both approaches, with one-ULP and separated controls. IOR1/zero absorption makes throughput1 and unit-environment display188 independently consistent. Requiring all four events prevents a matching final PNG from masking a skipped interface.
- The reflected child route checks both approaches. Its prefactor is outer entry*(27/49)*(1/49); first red/green Beer exponents include one Outer mm plus two Inner mm. A second two-mm Inner segment and factor256/147 precede restoration of Outer. Those are the independently derived factors from the stated fixture. It observes actual queued reflected transport, not a stub. A dedicated nested TIR execution case is not added; no claim that it was run is made.
- The opt-in comparison requires the old inward offset for false and exact origin=segment_start for true on an actual first entry. Existing V1 negative/camera/work/Beer/IOR proposals remain intact.
## Dependencies and handoff
This review covers the render V2 packet and its delta, not the separate M2 engine addendum230f1204. Command help/Session tests must be reviewed and qualified as that separate dependency; direct host tests cannot prove them.
The five recorded shared before files matched at receipt read, but that is not an overlay grant. In particular current job/mod.rs has hash03b8cb130fd848a64d30048ecdbfea785e9144827336eb8859473293076bc7ee, different from the earlier V1 review support snapshot; its currently read progressive dielectric guard and nested default remain present. Preserve other owners/AreaLED and rebind all actual final source before integration/execution. Current required document hashes match the versions consulted in this session; AGENTS.md was read again in full. No private plan contents were invented.
Next: Engineer decides integration after remaining dependency reviews, then Performance alone validates freshly pinned actual source under a new grant. Keep source-only F1 closure separate from an observed regression pass. No shared or public action was taken by this reviewer.
@@ -0,0 +1,413 @@
--- a/crates/render/src/raytrace/dielectric.rs
+++ b/crates/render/src/raytrace/dielectric.rs
@@ -287,6 +287,114 @@
lo.x < a.x && lo.y < a.y && lo.z < a.z && hi.x > b.x && hi.y > b.y && hi.z > b.z
}
+/// A certified axis-aligned face point, shared by propagation and Beer intervals.
+/// This is used only by the explicit nested-box path, never by general spawn.
+#[derive(Clone, Copy)]
+pub(super) struct ExactBoxFace {
+ pub point: Vec3,
+ axis: usize,
+ outward: f64,
+ entering: bool,
+}
+
+impl ExactBoxFace {
+ pub fn spawn(self, direction: Vec3, reflected: bool) -> Result<super::Ray> {
+ let ray = super::Ray::new(self.point, direction, 0.0_f64.next_up(), super::MAX_DISTANCE_MM)?;
+ let side = super::component(ray.direction(), self.axis) * self.outward;
+ let exterior = if reflected { self.entering } else { !self.entering };
+ if (exterior && side <= 0.0) || (!exterior && side >= 0.0) {
+ return Err(TraceError::InvalidInput("nested box continuation is tangent or on the wrong side"));
+ }
+ Ok(ray)
+ }
+}
+
+/// Recover the exact constant-axis coordinate from the actual certified face.
+/// Tangential coordinates are validated, never clamped or displaced. Equal
+/// ray/other-face depths conservatively refuse independently of ordinal ties.
+pub(super) fn exact_box_face(
+ bounds: Bounds,
+ vertices: [Vec3; 3],
+ normal: Vec3,
+ position: Vec3,
+ incoming: super::Ray,
+) -> Result<ExactBoxFace> {
+ let mut face = None;
+ for axis in 0..3 {
+ let [a, b, c] = vertices.map(|v| super::component(v, axis));
+ if a == b && b == c {
+ if face.is_some() {
+ return Err(TraceError::InvalidInput("nested box face has ambiguous constant axes"));
+ }
+ face = Some((axis, a));
+ }
+ }
+ let (axis, plane) = face.ok_or(TraceError::InvalidInput("nested box face is not axis aligned"))?;
+ let lo = bounds.min();
+ let hi = bounds.max();
+ let outward = if plane == super::component(lo, axis) {
+ -1.0
+ } else if plane == super::component(hi, axis) {
+ 1.0
+ } else {
+ return Err(TraceError::InvalidInput("nested box face disagrees with its certificate"));
+ };
+ let axis_vector = match axis {
+ 0 => Vec3::X,
+ 1 => Vec3::Y,
+ 2 => Vec3::Z,
+ _ => return Err(TraceError::Internal("nested box face axis")),
+ };
+ if normal != axis_vector * outward || Triangle::new(vertices)?.geometric_normal() != Some(normal) {
+ return Err(TraceError::InvalidInput("nested box outward normal disagrees with its certified face"));
+ }
+ for vertex in vertices {
+ for a in 0..3 {
+ let coordinate = super::component(vertex, a);
+ if coordinate != super::component(lo, a) && coordinate != super::component(hi, a) {
+ return Err(TraceError::InvalidInput("nested box face vertex is not a certified corner"));
+ }
+ }
+ }
+ let point = match axis {
+ 0 => Vec3::new(plane, position.y, position.z),
+ 1 => Vec3::new(position.x, plane, position.z),
+ 2 => Vec3::new(position.x, position.y, plane),
+ _ => return Err(TraceError::Internal("nested box point axis")),
+ };
+ if !point.is_finite() {
+ return Err(TraceError::InvalidInput("nested box reconstructed point is not finite"));
+ }
+ let direction = super::component(incoming.direction(), axis);
+ if direction == 0.0 {
+ return Err(TraceError::InvalidInput("nested box incident ray is tangent"));
+ }
+ let depth = (plane - super::component(incoming.origin(), axis)) / direction;
+ let [min, max] = incoming.interval();
+ if !depth.is_finite() || depth < min || depth > max {
+ return Err(TraceError::InvalidInput("nested box face is outside the incident ray interval"));
+ }
+ for a in 0..3 {
+ if a == axis {
+ continue;
+ }
+ let coordinate = super::component(point, a);
+ if coordinate <= super::component(lo, a) || coordinate >= super::component(hi, a) {
+ return Err(TraceError::InvalidInput("nested box edge, corner or outside-face point is ambiguous"));
+ }
+ let d = super::component(incoming.direction(), a);
+ if d != 0.0 {
+ for plane in [super::component(lo, a), super::component(hi, a)] {
+ let other = (plane - super::component(incoming.origin(), a)) / d;
+ if other == depth {
+ return Err(TraceError::InvalidInput("nested box simultaneous face depths are ambiguous"));
+ }
+ }
+ }
+ }
+ Ok(ExactBoxFace { point, axis, outward, entering: direction * outward < 0.0 })
+}
+
#[cfg(test)]
#[path = "dielectric_tests.rs"]
pub(super) mod tests;
--- a/crates/render/src/raytrace/image.rs
+++ b/crates/render/src/raytrace/image.rs
@@ -380,6 +380,7 @@
struct OpticalTask {
certificates: [Option<Certificate>; MAX_OVERRIDES],
parents: [Option<usize>; MAX_OVERRIDES],
+ nested: bool,
pending: Option<DielectricPixel>,
}
@@ -539,7 +540,7 @@
if optical.capacity() != 1 {
return Err(error(TraceError::MemoryLimit));
}
- optical.push(OpticalTask { certificates, parents, pending: None });
+ optical.push(OpticalTask { certificates, parents, nested, pending: None });
}
if input.is_some() {
options.validate_environment_reflections(&scene)?;
@@ -1314,7 +1315,19 @@
let scene = PreparedScene { bvh, primitives: &self.looks, source: &self.scene };
// Optics always uses the certified geometric boundary, before optional opaque
// shading-normal effects. Distance starts before the numerical spawn offset.
- let shared = scene.resolve_hit(pending.ray, hit).map_err(error)?;
+ let mut shared = scene.resolve_hit(pending.ray, hit).map_err(error)?;
+ let exact_face = if optical.nested {
+ match state.selected.and_then(|index| optical.certificates.get(index).copied().flatten()) {
+ Some(Certificate::ClosedBox(bounds)) => {
+ let face = dielectric::exact_box_face(bounds, shared.vertices, shared.geometric_normal, shared.position, pending.ray).map_err(error)?;
+ shared.position = face.point;
+ Some(face)
+ }
+ _ => None,
+ }
+ } else {
+ None
+ };
if let Some(index) = state.path.medium {
let medium = self.options.dielectrics.get(index).ok_or_else(|| invalid("active dielectric medium"))?;
let distance = shared.position.dist(state.path.segment_start);
@@ -1400,8 +1413,14 @@
(optics, Vec3::new(f, f, f), Vec3::new(t, t, t), next_medium)
}
};
+ let spawn = |direction, reflected| {
+ match exact_face {
+ Some(face) => face.spawn(direction, reflected).map_err(error),
+ None => shared.spawn(direction).map_err(error),
+ }
+ };
state.push(OpticalPath {
- ray: shared.spawn(optics.reflected).map_err(error)?,
+ ray: spawn(optics.reflected, true)?,
throughput: dielectric::multiply(state.path.throughput, reflected_weight),
medium: state.path.medium,
segment_start: shared.position,
@@ -1410,7 +1429,7 @@
})?;
if let Some(direction) = optics.transmitted {
state.push(OpticalPath {
- ray: shared.spawn(direction).map_err(error)?,
+ ray: spawn(direction, false)?,
throughput: dielectric::multiply(state.path.throughput, transmitted_weight),
medium: next_medium,
segment_start: shared.position,
--- a/crates/render/src/raytrace/dielectric_tests.rs
+++ b/crates/render/src/raytrace/dielectric_tests.rs
@@ -642,3 +642,84 @@
let image = ::image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
}
+
+fn face_fixture(axis: usize, positive: bool) -> (Bounds, [Vec3; 3], Vec3, Vec3) {
+ let point = |a: f64, u: f64, v: f64| match axis {
+ 0 => Vec3::new(a, u, v),
+ 1 => Vec3::new(v, a, u),
+ 2 => Vec3::new(u, v, a),
+ _ => panic!("test axis"),
+ };
+ let bounds = Bounds::new(Vec3::new(1.0, 1.0, 1.0), Vec3::new(4.0, 4.0, 4.0)).unwrap();
+ let plane = if positive { 4.0 } else { 1.0 };
+ let mut vertices = [point(plane, 1.0, 1.0), point(plane, 4.0, 1.0), point(plane, 4.0, 4.0)];
+ if !positive {
+ vertices.swap(1, 2);
+ }
+ let normal = match axis { 0 => Vec3::X, 1 => Vec3::Y, 2 => Vec3::Z, _ => panic!("test axis") } * if positive { 1.0 } else { -1.0 };
+ (bounds, vertices, normal, point(plane, 2.5, 2.5))
+}
+
+#[test]
+fn exact_face_excludes_own_zero_depth_and_retains_one_ulp_neighbour_on_every_axis_and_side() {
+ use super::super::Ray;
+ for axis in 0..3 {
+ for positive in [false, true] {
+ let (bounds, vertices, normal, position) = face_fixture(axis, positive);
+ for entering in [false, true] {
+ let direction = if entering { -normal } else { normal };
+ let incoming = Ray::new(position - direction, direction, 0.0, 10.0).unwrap();
+ let face = exact_box_face(bounds, vertices, normal, position, incoming).unwrap();
+ assert_eq!(face.point, position, "face diagonal is valid interior");
+ let optics = interface(direction, -direction, if entering { 1.0 } else { 1.5 }, if entering { 1.5 } else { 1.0 }).unwrap();
+ let reflected = face.spawn(optics.reflected, true).unwrap();
+ let transmitted = face.spawn(optics.transmitted.unwrap(), false).unwrap();
+ let triangle = Triangle::new(vertices).unwrap();
+ assert!(triangle.intersect(&reflected).is_none(), "own reflected face at t0 must be excluded");
+ assert!(triangle.intersect(&transmitted).is_none(), "own transmitted face at t0 must be excluded");
+ assert_eq!(reflected.origin(), face.point);
+ assert_eq!(transmitted.origin(), face.point);
+ assert_eq!(transmitted.interval()[0], 0.0_f64.next_up());
+ let plane = super::super::component(position, axis);
+ let next = if super::super::component(direction, axis) > 0.0 { plane.next_up() } else { plane.next_down() };
+ let neighbour = vertices.map(|v| match axis {
+ 0 => Vec3::new(next, v.y, v.z),
+ 1 => Vec3::new(v.x, next, v.z),
+ 2 => Vec3::new(v.x, v.y, next),
+ _ => panic!("test axis"),
+ });
+ let hit = Triangle::new(neighbour).unwrap().intersect(&transmitted).expect("one-ULP positive face must remain ahead");
+ assert_eq!(hit.distance, (next - plane).abs());
+ }
+ }
+ }
+}
+
+#[test]
+fn exact_face_recovers_certified_plane_without_clamping_or_blessing_ties() {
+ use super::super::Ray;
+ let (bounds, vertices, normal, point) = face_fixture(2, false);
+ let incoming = Ray::new(point - Vec3::Z, Vec3::Z, 0.0, 10.0).unwrap();
+ let rounded = Vec3::new(point.x, point.y, point.z.next_up());
+ let face = exact_box_face(bounds, vertices, normal, rounded, incoming).unwrap();
+ assert_eq!(face.point, point);
+ for bad in [
+ Vec3::new(1.0, 2.5, 1.0), Vec3::new(1.0, 1.0, 1.0),
+ Vec3::new(1.0_f64.next_down(), 2.5, 1.0), Vec3::new(f64::NAN, 2.5, 1.0),
+ ] {
+ assert!(exact_box_face(bounds, vertices, normal, bad, incoming).is_err());
+ }
+ let tied = Ray::new(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0), 0.0, 10.0).unwrap();
+ // Even if rounded tangential reconstruction claims interior, independently
+ // equal actual ray/face depths cannot inherit the BVH ordinal's blessing.
+ let uncertain = Vec3::new(1.0_f64.next_up(), 1.0_f64.next_up(), 1.0);
+ assert!(exact_box_face(bounds, vertices, normal, uncertain, tied).is_err());
+ assert!(exact_box_face(bounds, vertices, -normal, point, incoming).is_err());
+ let other_bounds = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
+ assert!(exact_box_face(other_bounds, vertices, normal, point, incoming).is_err());
+ let tangent = Ray::new(point - Vec3::X, Vec3::X, 0.0, 10.0).unwrap();
+ assert!(exact_box_face(bounds, vertices, normal, point, tangent).is_err());
+ assert!(face.spawn(Vec3::X, true).is_err());
+ assert!(face.spawn(-Vec3::Z, false).is_err());
+ assert!(face.spawn(Vec3::Z, true).is_err());
+}
--- a/crates/render/src/raytrace/image_tests.rs
+++ b/crates/render/src/raytrace/image_tests.rs
@@ -1521,3 +1521,141 @@
}
panic!("inside camera was not refused");
}
+
+fn close_gap_nested_task(gap: f64, positive: bool) -> ImageTask {
+ use std::sync::Arc;
+ let mut outer = dielectric::tests::box_body(3.0);
+ outer.name = "OuterGap".into();
+ for p in &mut Arc::make_mut(&mut outer.mesh).positions {
+ *p = Vec3::new(p.x * 0.2, p.y * 0.2, p.z + 1.0);
+ }
+ let mut inner = dielectric::tests::box_body(2.0);
+ inner.name = "InnerGap".into();
+ for p in &mut Arc::make_mut(&mut inner.mesh).positions {
+ *p = Vec3::new(p.x * 0.1, p.y * 0.1, if p.z == 0.0 { 1.0 + gap } else { 3.0 });
+ }
+ let mut scene = dielectric::tests::scene(outer);
+ scene.meshes.push(inner);
+ let options = RaytraceOptions {
+ dielectrics: scene.meshes.iter().enumerate().map(|(body_index, body)| job::DielectricOptions {
+ body_index, body_name:body.name.clone(), boundary:job::DielectricBoundary::ClosedBox,
+ ior:1.0, absorption_per_mm:[0.0;3], thickness_mm:0.0,
+ }).collect(),
+ lights:Some(Vec::new()), shadows:false,
+ environment:Some(job::RaytraceEnvironmentOptions { color:[1.0;3], intensity:1.0 }),
+ ..Default::default()
+ };
+ let mut request = dielectric::tests::request(options.clone());
+ request.camera.set_view(if positive { crate::StandardView::Bottom } else { crate::StandardView::Top });
+ request.camera.target = Vec3::new(0.0,0.0,2.5);
+ request.camera.fov = 0.0;
+ ImageTask::new_with_environment_and_nested(scene, request, CancelToken::default(), options, None, true).unwrap()
+}
+
+#[test]
+fn positive_one_ulp_nested_gap_and_separated_control_visit_every_real_interface() {
+ for gap in [f64::EPSILON, 0.25] {
+ for positive in [false, true] {
+ let mut task = close_gap_nested_task(gap, positive);
+ let points = if positive { [1.0,1.0+gap,3.0,4.0] } else { [4.0,3.0,1.0+gap,1.0] };
+ let media = [Some(0),Some(1),Some(0),None];
+ let mut next = 0;
+ let mut completed = false;
+ for _ in 0..100_000 {
+ match task.step(1).unwrap() {
+ TaskPoll::Pending(_) => {
+ if next < points.len()
+ && let Some(state) = task.optical.first().and_then(|optical| optical.pending.as_ref())
+ && state.path.boundaries == next as u8 + 1
+ {
+ assert_eq!(state.path.medium, media[next]);
+ assert_eq!(state.path.segment_start.z, points[next]);
+ assert_eq!(state.path.ray.origin(), state.path.segment_start, "propagation and Beer start share one physical point");
+ assert_eq!(state.path.throughput, Vec3::new(1.0,1.0,1.0));
+ next += 1;
+ }
+ }
+ TaskPoll::Complete(png) => {
+ assert_eq!(next, 4, "an admitted interface was skipped");
+ let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
+ assert_eq!(image.get_pixel(0,0).0, [188,188,188,255]);
+ completed = true;
+ break;
+ }
+ }
+ }
+ assert!(completed, "close-gap image failed to complete");
+ }
+ }
+}
+
+#[test]
+fn nested_reflected_route_keeps_child_beer_then_restores_parent_from_both_sides() {
+ for positive in [false, true] {
+ let (scene, mut options) = dielectric::tests::nested_fixture(false);
+ for medium in &mut options.dielectrics {
+ medium.ior = if medium.body_name == "Outer" { 1.5 } else { 2.0 };
+ }
+ let mut request = dielectric::tests::request(options.clone());
+ request.camera.set_view(if positive { crate::StandardView::Bottom } else { crate::StandardView::Top });
+ request.camera.target = Vec3::new(0.0,0.0,2.0);
+ request.camera.fov = 0.0;
+ let mut task = ImageTask::new_with_environment_and_nested(scene, request, CancelToken::default(), options, None, true).unwrap();
+ let (near, far) = if positive { (1.0,3.0) } else { (3.0,1.0) };
+ let factor = (0.96 / 2.25) * (27.0 / 49.0) / 49.0;
+ let reflected = Vec3::new(factor * (-0.1_f64 - 0.3*2.0).exp(), factor * (-0.2_f64 - 0.4*2.0).exp(), factor);
+ let returned = dielectric::multiply(reflected, Vec3::new((-0.3_f64*2.0).exp(),(-0.4_f64*2.0).exp(),1.0)) * (256.0/147.0);
+ let mut saw_reflected = false;
+ let mut restored = false;
+ for _ in 0..1_000_000 {
+ if let TaskPoll::Complete(_) = task.step(1).unwrap() {
+ break;
+ }
+ let Some(state) = task.optical.first().and_then(|optical| optical.pending.as_ref()) else { continue };
+ let path = state.path;
+ if path.boundaries == 3 && path.medium == Some(1) && path.segment_start.z == far {
+ assert!((path.throughput - reflected).len() < 1e-12);
+ assert_eq!(path.ray.origin(), path.segment_start);
+ assert!(path.ray.direction().z * if positive { -1.0 } else { 1.0 } > 0.0);
+ saw_reflected = true;
+ }
+ if saw_reflected && path.boundaries == 4 && path.medium == Some(0) && path.segment_start.z == near {
+ assert!((path.throughput - returned).len() < 1e-12);
+ assert_eq!(path.ray.origin(), path.segment_start);
+ restored = true;
+ break;
+ }
+ }
+ assert!(saw_reflected && restored, "actual reflected child route never restored its parent");
+ }
+}
+
+#[test]
+fn exact_box_launch_is_opt_in_and_legacy_default_origin_is_preserved() {
+ for nested in [false, true] {
+ let mut material = dielectric::tests::input(job::DielectricBoundary::ClosedBox);
+ material.ior = 1.0;
+ let options = RaytraceOptions { dielectrics:vec![material], lights:Some(Vec::new()), shadows:false, ..Default::default() };
+ let request = dielectric::tests::request(options.clone());
+ let mut task = ImageTask::new_with_environment_and_nested(
+ dielectric::tests::scene(dielectric::tests::box_body(2.0)), request, CancelToken::default(), options, None, nested,
+ ).unwrap();
+ let mut observed = false;
+ for _ in 0..100_000 {
+ assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
+ if let Some(state) = task.optical.first().and_then(|optical| optical.pending.as_ref())
+ && state.path.boundaries == 1 && state.path.medium == Some(0)
+ {
+ assert_eq!(state.path.segment_start.z, 2.0);
+ if nested {
+ assert_eq!(state.path.ray.origin(), state.path.segment_start);
+ } else {
+ assert!(state.path.ray.origin().z < state.path.segment_start.z, "default must retain its original inward offset");
+ }
+ observed = true;
+ break;
+ }
+ }
+ assert!(observed, "first box entry was not observed");
+ }
+}
@@ -0,0 +1,5 @@
# Actual CPU Interactive Area/LED end-to-end cases
Source-only checkpoint of two original tests, appended to the existing UI test file. These use the actual snapshot, CPU host, reply PNG and receiver cache rather than injected images. One covers real Area/LED pixels reaching Interactive without final-export ownership. The other holds already-completed real replies across a light edit and final-job preemption, requiring stale replies to be discarded without erasing the final job. It does not prove cancellation of an in-flight render or long-render convergence.
Independently reviewed and applied locally before the current consolidated C12 validation. No compilation or test result for these two cases is claimed by this archive. The patch precedes the validation formatter. Actual source/artifact-bound results must be reported separately. Full CI, fresh desktop acceptance, GPU and general glass parity remain pending.
@@ -0,0 +1,9 @@
# CPU progressive rectangle and LED increment
Applied locally, unformatted/uncompiled/unrun. Existing bounded rectangle/LED direct-light transport already consumes the film ordinal, source seed, area PDF/power, cancellation and cumulative work. This increment removes only the progressive area refusal, retaining primary-sample/reflection-attempt, dielectric/anisotropic and resource guards. It adds8 original cases, updates2 incompatibility fixtures legitimately, and updates existing command/docs/help. No light caps, CPU profiles or pool ceilings are raised.
The seven consumer/admission cases and later complete-request preview-lowering case received independent static review; no actual compiler/test/image result is claimed.25 existing translations remove only the obsolete area-must-use-Single-image clause. The earlier preview-help update is a distinct ID and remains intact.
Root's actual docs follow-up also replaces a stale global512MiB host-pool sentence with the current resource-profile reference; this is a documentation correction, not a resource-code change. The source patch here precedes that one sentence correction.
The new consolidated serial validation will cover actual Render/engine/UI artifacts and targeted bodies. Long native progressive accumulation, image quality and complete CPU/GPU/Raster parity remain open. This does not enable progressive glass.
@@ -0,0 +1,133 @@
--- a/crates/ui-egui/src/render_workspace_tests.rs
+++ b/crates/ui-egui/src/render_workspace_tests.rs
@@ -8267,3 +8267,130 @@
assert!(h.app.render_workspace.view.job.is_none());
}
}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn interactive_area_and_led_real_snapshot_pixels_reach_receiver_cache_without_final_export() {
+ use solidcraft_engine::render::job::{RectangleLightOptions, led_strip::LedStripOptions};
+ let mut h = interactive_ui_fixture();
+ if !interactive_test_cpu_branch(&mut h, "interactive_area_and_led_real_snapshot_pixels_reach_receiver_cache_without_final_export") {
+ return;
+ }
+ h.app.render_workspace.view.raytrace.area_lights = Some(vec![RectangleLightOptions {
+ center_mm: [5.0, 4.0, 20.0], edge_u_mm: [4.0, 0.0, 0.0], edge_v_mm: [0.0, -4.0, 0.0],
+ color: [1.0, 0.5, 0.25], intensity: 2.0,
+ }]);
+ h.app.render_workspace.view.raytrace.area_samples = Some(4);
+ h.app.render_workspace.view.raytrace.seed = Some(73);
+ LedStripOptions {
+ center_mm: [5.0, 4.0, 12.0], length_mm: 8.0, width_mm: 1.0,
+ length_axis: [1.0, 0.0, 0.0], width_axis: [0.0, -1.0, 0.0], color: [0.25, 0.5, 1.0], intensity: 3.0,
+ }.append_to(&mut h.app.render_workspace.view.raytrace, 4).unwrap();
+ h.app.render_workspace.view.draft.exposure_ev = 0.75;
+ let expected = h.app.render_workspace.view.raytrace.area_lights.clone().unwrap();
+ assert_eq!(expected.len(), 2);
+ let document = h.app.session.doc.clone();
+ let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.is_dirty());
+ let epoch = interactive::test_clock(&h.app).as_secs_f64();
+ h.app.run("render.interactive", json!({"action":"on"})).unwrap();
+ // Existing helper dispatches the real Snapshot, receives its real reply,
+ // validates real native PNG bytes/hash, drains the reply and paints the cache.
+ let id = interactive_recovery_require_success(&mut h, epoch + 0.201, epoch + 0.3);
+ let job = interactive::test_current(&h.app).unwrap().unwrap();
+ assert_eq!(job.id(), id);
+ let status = job.status().unwrap();
+ let options = status.request.raytrace.as_ref().unwrap();
+ assert_eq!(options.area_lights.as_ref(), Some(&expected));
+ assert_eq!(options.area_samples, Some(4));
+ assert_eq!(options.seed, Some(73));
+ assert_eq!(options.exposure_ev, 0.75);
+ assert_eq!(options.progressive.unwrap().samples_per_pixel, 16);
+ let (cached, samples, complete, _) = interactive::test_cache(&h.app).unwrap();
+ let frame = job.frame().unwrap().unwrap();
+ assert_eq!((cached.job, cached.png_sha256), (id, frame.info.png_sha256.clone()));
+ assert_eq!((cached.width, cached.height, samples, complete), (16, 16, 16, true));
+ assert_eq!(interactive::inspect(&h.app)["provisional"], true);
+ assert!(!h.app.render_workspace.interactive.pending());
+ assert!(Arc::ptr_eq(&document, &h.app.session.doc));
+ assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.is_dirty()), history);
+ interactive_assert_final_export_isolation(&mut h, &job);
+ h.app.run("render.interactive", json!({"action":"off"})).unwrap();
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn interactive_led_light_edit_and_final_preemption_discard_real_late_replies_without_erasing_final() {
+ use solidcraft_engine::render::job::{CpuHost, JobId, Phase, led_strip::LedStripOptions};
+ let mut h = interactive_ui_fixture();
+ if !interactive_test_cpu_branch(&mut h, "interactive_led_light_edit_and_final_preemption_discard_real_late_replies_without_erasing_final") {
+ return;
+ }
+ crate::documents::open_tab(&mut h.app, Session::default()).unwrap();
+ crate::documents::switch(&mut h.app, 0);
+ h.app.ui.tab = "RENDER".into();
+ h.app.render_workspace.view.method = ImageMethod::CpuRaytrace;
+ h.app.render_workspace.view.draft.width = 16;
+ h.app.render_workspace.view.draft.height = 16;
+ LedStripOptions {
+ center_mm: [5.0, 4.0, 12.0], length_mm: 8.0, width_mm: 1.0,
+ length_axis: [1.0, 0.0, 0.0], width_axis: [0.0, -1.0, 0.0], color: [1.0, 0.5, 0.25], intensity: 2.0,
+ }.append_to(&mut h.app.render_workspace.view.raytrace, 4).unwrap();
+ h.app.render_workspace.view.raytrace.seed = Some(91);
+ let document = h.app.session.doc.clone();
+ let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.is_dirty());
+ let epoch = interactive::test_clock(&h.app).as_secs_f64();
+ h.app.run("render.interactive", json!({"action":"on"})).unwrap();
+ interactive_clear_acceptance_frame(&mut h, epoch + 0.201, true);
+ let first = interactive_hold_real_completed_callback(&mut h);
+ let old = first.job.id();
+ let old_options = first.job.status().unwrap().request.raytrace.unwrap();
+ assert_eq!(old_options.area_lights.as_ref().unwrap().first().unwrap().intensity, 2.0);
+ assert_eq!((old_options.area_samples, old_options.seed), (Some(4), Some(91)));
+ let visual = interactive::inspect(&h.app)["visual_revision"].as_u64().unwrap();
+ h.app.render_workspace.view.raytrace.area_lights.as_mut().unwrap().first_mut().unwrap().intensity = 1.5;
+ interactive_clear_acceptance_frame(&mut h, epoch + 0.3, false);
+ assert!(interactive::inspect(&h.app)["visual_revision"].as_u64().unwrap() > visual);
+ assert!(interactive::test_current(&h.app).unwrap().is_none(), "light edit must detach the old owned job");
+ first.sender.send(first.reply).unwrap();
+ let (mut output, painted) = interactive_acceptance_frame(&mut h, epoch + 0.4, false, true);
+ assert!(!painted);
+ output.textures_delta.clear();
+ assert!(interactive::test_cache(&h.app).is_none());
+ assert!(!h.app.render_workspace.interactive.pending());
+ assert!(interactive::inspect(&h.app)["error"].is_null());
+ interactive_assert_final_export_isolation(&mut h, &first.job);
+
+ interactive_clear_acceptance_frame(&mut h, epoch + 0.601, true);
+ let second = interactive_hold_real_completed_callback(&mut h);
+ assert_ne!(second.job.id(), old);
+ let new_options = second.job.status().unwrap().request.raytrace.unwrap();
+ assert_eq!(new_options.area_lights.as_ref().unwrap().first().unwrap().intensity, 1.5);
+ assert_eq!((new_options.area_samples, new_options.seed), (Some(4), Some(91)));
+ let final_host = CpuHost::cooperative();
+ let final_job = {
+ let background = h.app.docs.background_mut().next().unwrap();
+ background.session.set_render_job_host(Some(final_host.clone())).unwrap();
+ let reply = background.session.execute("render.start", &json!({"settings":{"width":8,"height":8}})).unwrap();
+ let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
+ background.session.render_job_handle(id).unwrap()
+ };
+ let retained = final_host.admitted_bytes();
+ interactive_clear_acceptance_frame(&mut h, epoch + 0.7, false);
+ assert_eq!(interactive::inspect(&h.app)["phase"], "Paused");
+ second.sender.send(second.reply).unwrap();
+ let (mut output, painted) = interactive_acceptance_frame(&mut h, epoch + 0.8, false, true);
+ assert!(!painted, "a final-suspended late LED reply cannot fill the preview cache");
+ output.textures_delta.clear();
+ assert!(interactive::test_cache(&h.app).is_none());
+ assert!(interactive::test_current(&h.app).unwrap().is_none());
+ assert!(!h.app.render_workspace.interactive.pending());
+ assert!(interactive::inspect(&h.app)["error"].is_null());
+ interactive_assert_final_export_isolation(&mut h, &second.job);
+ assert_eq!(final_job.status().unwrap().progress.phase, Phase::Queued);
+ assert_eq!(final_host.admitted_bytes(), retained);
+ assert!(Arc::ptr_eq(&document, &h.app.session.doc));
+ assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.is_dirty()), history);
+ h.app.run("render.interactive", json!({"action":"off"})).unwrap();
+ assert_eq!(final_job.status().unwrap().progress.phase, Phase::Queued, "Off cannot cancel the unrelated final");
+ final_job.cancel().unwrap();
+}
@@ -0,0 +1,300 @@
diff --git a/assets/locales/ar.json b/assets/locales/ar.json
--- a/assets/locales/ar.json
+++ b/assets/locales/ar.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "حتى ثمانية أضواء نقطية تُضاف إلى الإضاءة الاتجاهية المختارة. المسافات بالمليمتر؛ ويجب ألا يتجاوز الحد الأدنى المسافة المرجعية.",
"render.cpu_primary_samples": "عيّنات الحواف لكل بكسل",
"render.cpu_progressive": "CPU تدريجي",
- "render.cpu_progressive_help": "يُراكم مُكامل CPU الحالي على تمريرات من عيّنة أساسية واحدة. الانعكاسات المختارة تتطلب عيّنة واحدة؛ والأضواء المساحية والعوازل وتباين الخواص تتطلب «صورة واحدة».",
+ "render.cpu_progressive_help": "يُراكم مُكامل CPU الحالي على تمريرات من عيّنة أساسية واحدة. الانعكاسات المختارة تتطلب عيّنة واحدة؛ والعوازل وتباين الخواص تتطلب «صورة واحدة».",
"render.cpu_progressive_limits": "بحد أقصى 1,048,576 بكسل و16,777,216 عملية تقييم بكسل × عيّنة. تغيير العيّنات لا يغيّر حجم الصورة تلقائيًا أبدًا.",
"render.cpu_progressive_seed": "بذرة عيّنات CPU",
"render.cpu_progressive_spp": "عيّنات لكل بكسل",
diff --git a/assets/locales/bs.json b/assets/locales/bs.json
--- a/assets/locales/bs.json
+++ b/assets/locales/bs.json
@@ -2159,7 +2159,7 @@
"render.cpu_point_help": "Do osam tačkastih svjetala, dodanih izabranom usmjerenom osvjetljenju. Udaljenosti su u mm; minimalna ne smije premašiti referentnu udaljenost.",
"render.cpu_primary_samples": "Uzorci ivica po pikselu",
"render.cpu_progressive": "CPU progresivno",
- "render.cpu_progressive_help": "Akumulira postojeći CPU integrator u prolazima od po jednog primarnog uzorka. Izabrane refleksije zahtijevaju jedan uzorak; površinska svjetla, dielektrici i anizotropija zahtijevaju Jedna slika.",
+ "render.cpu_progressive_help": "Akumulira postojeći CPU integrator u prolazima od po jednog primarnog uzorka. Izabrane refleksije zahtijevaju jedan uzorak; dielektrici i anizotropija zahtijevaju Jedna slika.",
"render.cpu_progressive_limits": "Najviše 1.048.576 piksela i 16.777.216 izračunavanja piksel × uzorak. Promjena uzoraka nikad automatski ne mijenja veličinu slike.",
"render.cpu_progressive_seed": "CPU sjeme uzorkovanja",
"render.cpu_progressive_spp": "uzoraka po pikselu",
diff --git a/assets/locales/cs.json b/assets/locales/cs.json
--- a/assets/locales/cs.json
+++ b/assets/locales/cs.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Nejvýše osm bodových světel, přidaných k vybranému směrovému osvětlení. Vzdálenosti jsou v mm; minimum nesmí překročit referenční vzdálenost.",
"render.cpu_primary_samples": "Vzorky hran na pixel",
"render.cpu_progressive": "Progresivní CPU",
- "render.cpu_progressive_help": "Akumuluje stávající integrátor CPU v průchodech po jednom primárním vzorku. Vybrané odrazy vyžadují jeden vzorek; plošná světla, dielektrika a anizotropie vyžadují režim Jeden obrázek.",
+ "render.cpu_progressive_help": "Akumuluje stávající integrátor CPU v průchodech po jednom primárním vzorku. Vybrané odrazy vyžadují jeden vzorek; dielektrika a anizotropie vyžadují režim Jeden obrázek.",
"render.cpu_progressive_limits": "Nejvýše 1 048 576 pixelů a 16 777 216 vyhodnocení pixel × vzorek. Změna počtu vzorků nikdy automaticky nezmění velikost obrázku.",
"render.cpu_progressive_seed": "Náhodné semeno vzorkování CPU",
"render.cpu_progressive_spp": "vzorků na pixel",
diff --git a/assets/locales/en.json b/assets/locales/en.json
--- a/assets/locales/en.json
+++ b/assets/locales/en.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "Up to eight point lights, added to the selected directional lighting. Distances are in mm; the minimum must not exceed the reference distance.",
"render.cpu_primary_samples": "Edge samples per pixel",
"render.cpu_progressive": "CPU progressive",
- "render.cpu_progressive_help": "Accumulate the existing CPU integrator in passes of one primary sample. Selected reflections require one sample; area lights, dielectrics and anisotropy require Single image.",
+ "render.cpu_progressive_help": "Accumulate the existing CPU integrator in passes of one primary sample. Selected reflections require one sample; dielectrics and anisotropy require Single image.",
"render.cpu_progressive_limits": "At most 1,048,576 pixels and 16,777,216 pixel × sample evaluations. Changing samples never resizes the image automatically.",
"render.cpu_progressive_seed": "CPU sampling seed",
"render.cpu_progressive_spp": "samples per pixel",
diff --git a/assets/locales/es.json b/assets/locales/es.json
--- a/assets/locales/es.json
+++ b/assets/locales/es.json
@@ -1954,7 +1954,7 @@
"render.cpu_point_help": "Hasta ocho luces puntuales, añadidas a la iluminación direccional elegida. Las distancias están en mm; la mínima no puede superar la de referencia.",
"render.cpu_primary_samples": "Muestras de borde por píxel",
"render.cpu_progressive": "CPU progresivo",
- "render.cpu_progressive_help": "Acumula el integrador CPU actual en pasadas de una muestra primaria. Los reflejos seleccionados requieren una muestra; luces de área, dieléctricos y anisotropía requieren Imagen fija.",
+ "render.cpu_progressive_help": "Acumula el integrador CPU actual en pasadas de una muestra primaria. Los reflejos seleccionados requieren una muestra; dieléctricos y anisotropía requieren Imagen fija.",
"render.cpu_progressive_limits": "Máximo de 1.048.576 píxeles y 16.777.216 evaluaciones de píxel × muestra. Cambiar las muestras nunca redimensiona la imagen automáticamente.",
"render.cpu_progressive_seed": "Semilla de muestreo CPU",
"render.cpu_progressive_spp": "muestras por píxel",
diff --git a/assets/locales/fr.json b/assets/locales/fr.json
--- a/assets/locales/fr.json
+++ b/assets/locales/fr.json
@@ -2169,7 +2169,7 @@
"render.cpu_point_help": "Jusqu'à huit lumières ponctuelles, ajoutées à l'éclairage directionnel choisi. Les distances sont en mm ; la distance minimale ne doit pas dépasser la distance de référence.",
"render.cpu_primary_samples": "Échantillons de bord par pixel",
"render.cpu_progressive": "CPU progressif",
- "render.cpu_progressive_help": "Accumuler l'intégrateur CPU existant par passes d'un échantillon primaire. Les réflexions sélectionnées exigent un échantillon ; les lumières surfaciques, les diélectriques et l'anisotropie exigent Image unique.",
+ "render.cpu_progressive_help": "Accumuler l'intégrateur CPU existant par passes d'un échantillon primaire. Les réflexions sélectionnées exigent un échantillon ; les diélectriques et l'anisotropie exigent Image unique.",
"render.cpu_progressive_limits": "Au plus 1 048 576 pixels et 16 777 216 évaluations pixel × échantillon. Changer le nombre d'échantillons ne redimensionne jamais l'image automatiquement.",
"render.cpu_progressive_seed": "Graine d'échantillonnage CPU",
"render.cpu_progressive_spp": "échantillons par pixel",
diff --git a/assets/locales/gl.json b/assets/locales/gl.json
--- a/assets/locales/gl.json
+++ b/assets/locales/gl.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Ata oito luces puntuais, engadidas á iluminación direccional seleccionada. As distancias están en mm; a mínima non pode superar a distancia de referencia.",
"render.cpu_primary_samples": "Mostras de bordo por píxel",
"render.cpu_progressive": "Progresivo da CPU",
- "render.cpu_progressive_help": "Acumula o integrador da CPU existente en pasadas dunha mostra primaria. As reflexións seleccionadas requiren unha mostra; as luces de área, os dieléctricos e a anisotropía requiren Imaxe única.",
+ "render.cpu_progressive_help": "Acumula o integrador da CPU existente en pasadas dunha mostra primaria. As reflexións seleccionadas requiren unha mostra; os dieléctricos e a anisotropía requiren Imaxe única.",
"render.cpu_progressive_limits": "Como máximo 1.048.576 píxeles e 16.777.216 avaliacións de píxel × mostra. Cambiar as mostras nunca redimensiona a imaxe automaticamente.",
"render.cpu_progressive_seed": "Semente de mostraxe da CPU",
"render.cpu_progressive_spp": "mostras por píxel",
diff --git a/assets/locales/he.json b/assets/locales/he.json
--- a/assets/locales/he.json
+++ b/assets/locales/he.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "עד שמונה אורות נקודתיים, המתווספים לתאורה הכיוונית שנבחרה. המרחקים במילימטרים; המינימום אינו יכול לעלות על מרחק הייחוס.",
"render.cpu_primary_samples": "דגימות קצה לכל פיקסל",
"render.cpu_progressive": "CPU הדרגתי",
- "render.cpu_progressive_help": "צבירה של אינטגרטור ה־CPU הקיים במעברים של דגימה ראשית אחת. החזרות שנבחרו דורשות דגימה אחת; אורות שטח, דיאלקטריים ואנאיזוטרופיה דורשים תמונה בודדת.",
+ "render.cpu_progressive_help": "צבירה של אינטגרטור ה־CPU הקיים במעברים של דגימה ראשית אחת. החזרות שנבחרו דורשות דגימה אחת; דיאלקטריים ואנאיזוטרופיה דורשים תמונה בודדת.",
"render.cpu_progressive_limits": "לכל היותר 1,048,576 פיקסלים ו־16,777,216 הערכות פיקסל × דגימה. שינוי הדגימות לעולם אינו משנה את גודל התמונה אוטומטית.",
"render.cpu_progressive_seed": "זרע דגימה ב־CPU",
"render.cpu_progressive_spp": "דגימות לכל פיקסל",
diff --git a/assets/locales/hr.json b/assets/locales/hr.json
--- a/assets/locales/hr.json
+++ b/assets/locales/hr.json
@@ -2159,7 +2159,7 @@
"render.cpu_point_help": "Do osam točkastih svjetala, dodanih odabranom usmjerenom osvjetljenju. Udaljenosti su u mm; minimalna ne smije premašiti referentnu udaljenost.",
"render.cpu_primary_samples": "Uzorci rubova po pikselu",
"render.cpu_progressive": "CPU progresivno",
- "render.cpu_progressive_help": "Akumulira postojeći CPU integrator u prolazima od po jednog primarnog uzorka. Odabrane refleksije zahtijevaju jedan uzorak; površinska svjetla, dielektrici i anizotropija zahtijevaju Jedna slika.",
+ "render.cpu_progressive_help": "Akumulira postojeći CPU integrator u prolazima od po jednog primarnog uzorka. Odabrane refleksije zahtijevaju jedan uzorak; dielektrici i anizotropija zahtijevaju Jedna slika.",
"render.cpu_progressive_limits": "Najviše 1.048.576 piksela i 16.777.216 izračuna piksel × uzorak. Promjena uzoraka nikad automatski ne mijenja veličinu slike.",
"render.cpu_progressive_seed": "CPU sjeme uzorkovanja",
"render.cpu_progressive_spp": "uzoraka po pikselu",
diff --git a/assets/locales/hu.json b/assets/locales/hu.json
--- a/assets/locales/hu.json
+++ b/assets/locales/hu.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Legfeljebb nyolc pontfény, amelyek a kijelölt irányított megvilágításhoz adódnak hozzá. A távolságok mm-ben értendők; a minimum nem haladhatja meg a referenciatávolságot.",
"render.cpu_primary_samples": "Élminták pixelenként",
"render.cpu_progressive": "Progresszív CPU",
- "render.cpu_progressive_help": "A meglévő CPU-integrátor eredményeinek felhalmozása egy elsődleges mintából álló menetekben. A kijelölt tükröződésekhez egy minta szükséges; a területi fényekhez, a dielektrikumokhoz és az anizotrópiához az Egyetlen kép mód kell.",
+ "render.cpu_progressive_help": "A meglévő CPU-integrátor eredményeinek felhalmozása egy elsődleges mintából álló menetekben. A kijelölt tükröződésekhez egy minta szükséges; a dielektrikumokhoz és az anizotrópiához az Egyetlen kép mód kell.",
"render.cpu_progressive_limits": "Legfeljebb 1 048 576 képpont és 16 777 216 képpont × minta kiértékelés. A minták módosítása sosem méretezi át automatikusan a képet.",
"render.cpu_progressive_seed": "CPU-mintavételi kezdőérték",
"render.cpu_progressive_spp": "minta pixelenként",
diff --git a/assets/locales/id.json b/assets/locales/id.json
--- a/assets/locales/id.json
+++ b/assets/locales/id.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Hingga delapan lampu titik, ditambahkan ke pencahayaan arah yang dipilih. Jarak dalam mm; minimum tidak boleh melebihi jarak referensi.",
"render.cpu_primary_samples": "Sampel tepi per piksel",
"render.cpu_progressive": "CPU progresif",
- "render.cpu_progressive_help": "Akumulasikan integrator CPU yang ada dalam pass berisi satu sampel primer. Pantulan yang dipilih memerlukan satu sampel; lampu area, dielektrik, dan anisotropi memerlukan Gambar tunggal.",
+ "render.cpu_progressive_help": "Akumulasikan integrator CPU yang ada dalam pass berisi satu sampel primer. Pantulan yang dipilih memerlukan satu sampel; dielektrik dan anisotropi memerlukan Gambar tunggal.",
"render.cpu_progressive_limits": "Paling banyak 1.048.576 piksel dan 16.777.216 evaluasi piksel × sampel. Mengubah sampel tidak pernah mengubah ukuran gambar secara otomatis.",
"render.cpu_progressive_seed": "Seed sampling CPU",
"render.cpu_progressive_spp": "sampel per piksel",
diff --git a/assets/locales/it.json b/assets/locales/it.json
--- a/assets/locales/it.json
+++ b/assets/locales/it.json
@@ -2150,7 +2150,7 @@
"render.cpu_point_help": "Fino a otto luci puntiformi, aggiunte all'illuminazione direzionale selezionata. Le distanze sono in mm; il minimo non deve superare la distanza di riferimento.",
"render.cpu_primary_samples": "Campioni dei bordi per pixel",
"render.cpu_progressive": "CPU progressiva",
- "render.cpu_progressive_help": "Accumula l'integratore CPU esistente in passate di un campione primario. Le riflessioni selezionate richiedono un solo campione; luci d'area, dielettrici e anisotropia richiedono Immagine singola.",
+ "render.cpu_progressive_help": "Accumula l'integratore CPU esistente in passate di un campione primario. Le riflessioni selezionate richiedono un solo campione; dielettrici e anisotropia richiedono Immagine singola.",
"render.cpu_progressive_limits": "Al massimo 1,048,576 pixel e 16,777,216 valutazioni pixel × campione. Cambiare i campioni non ridimensiona mai automaticamente l'immagine.",
"render.cpu_progressive_seed": "Seme di campionamento CPU",
"render.cpu_progressive_spp": "campioni per pixel",
diff --git a/assets/locales/ja.json b/assets/locales/ja.json
--- a/assets/locales/ja.json
+++ b/assets/locales/ja.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "選択した平行光に最大8個の点光源を追加できます。距離はmmで、最小距離は基準距離以下にしてください。",
"render.cpu_primary_samples": "輪郭サンプル数/ピクセル",
"render.cpu_progressive": "CPUプログレッシブ",
- "render.cpu_progressive_help": "既存のCPU積分器を、一次サンプル1個ずつのパスで累積します。選択した反射は1サンプルが必要です。面光源、誘電体、異方性には固定画像を使用してください。",
+ "render.cpu_progressive_help": "既存のCPU積分器を、一次サンプル1個ずつのパスで累積します。選択した反射は1サンプルが必要です。誘電体、異方性には固定画像を使用してください。",
"render.cpu_progressive_limits": "最大1,048,576ピクセル、ピクセル×サンプルの合計は16,777,216以下です。サンプル数を変えても画像サイズは自動変更されません。",
"render.cpu_progressive_seed": "CPUサンプリングシード",
"render.cpu_progressive_spp": "サンプル/ピクセル",
diff --git a/assets/locales/ms.json b/assets/locales/ms.json
--- a/assets/locales/ms.json
+++ b/assets/locales/ms.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Sehingga lapan lampu titik, ditambah pada pencahayaan berarah yang dipilih. Jarak dalam mm; jarak minimum tidak boleh melebihi jarak rujukan.",
"render.cpu_primary_samples": "Sampel tepi setiap piksel",
"render.cpu_progressive": "CPU progresif",
- "render.cpu_progressive_help": "Kumpulkan hasil penyepadu CPU sedia ada dalam pas satu sampel utama. Pantulan yang dipilih memerlukan satu sampel; lampu kawasan, dielektrik dan anisotropi memerlukan Imej tunggal.",
+ "render.cpu_progressive_help": "Kumpulkan hasil penyepadu CPU sedia ada dalam pas satu sampel utama. Pantulan yang dipilih memerlukan satu sampel; dielektrik dan anisotropi memerlukan Imej tunggal.",
"render.cpu_progressive_limits": "Paling banyak 1,048,576 piksel dan 16,777,216 penilaian piksel × sampel. Menukar sampel tidak mengubah saiz imej secara automatik.",
"render.cpu_progressive_seed": "Benih pensampelan CPU",
"render.cpu_progressive_spp": "sampel setiap piksel",
diff --git a/assets/locales/nb.json b/assets/locales/nb.json
--- a/assets/locales/nb.json
+++ b/assets/locales/nb.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Opptil åtte punktlys, lagt til i den valgte retningsbelysningen. Avstander er i mm; minimumsavstanden må ikke overstige referanseavstanden.",
"render.cpu_primary_samples": "Kantprøver per piksel",
"render.cpu_progressive": "Progressiv CPU",
- "render.cpu_progressive_help": "Akkumuler den eksisterende CPU-integratoren i omganger på én primærprøve. Valgte refleksjoner krever én prøve; arealys, dielektriske materialer og anisotropi krever Enkeltbilde.",
+ "render.cpu_progressive_help": "Akkumuler den eksisterende CPU-integratoren i omganger på én primærprøve. Valgte refleksjoner krever én prøve; dielektriske materialer og anisotropi krever Enkeltbilde.",
"render.cpu_progressive_limits": "Maksimalt 1 048 576 piksler og 16 777 216 evalueringer av piksel × prøve. Endring av antall prøver endrer aldri bildestørrelsen automatisk.",
"render.cpu_progressive_seed": "Startverdi for CPU-prøvetaking",
"render.cpu_progressive_spp": "prøver per piksel",
diff --git a/assets/locales/nl.json b/assets/locales/nl.json
--- a/assets/locales/nl.json
+++ b/assets/locales/nl.json
@@ -2148,7 +2148,7 @@
"render.cpu_point_help": "Maximaal acht puntlichten, toegevoegd aan de gekozen richtingsverlichting. Afstanden zijn in mm; het minimum mag de referentieafstand niet overschrijden.",
"render.cpu_primary_samples": "Randsamples per pixel",
"render.cpu_progressive": "CPU progressief",
- "render.cpu_progressive_help": "Accumuleert de bestaande CPU-integrator in passes van één primaire sample. Gekozen reflecties vereisen één sample; vlaklichten, diëlektrica en anisotropie vereisen Enkele afbeelding.",
+ "render.cpu_progressive_help": "Accumuleert de bestaande CPU-integrator in passes van één primaire sample. Gekozen reflecties vereisen één sample; diëlektrica en anisotropie vereisen Enkele afbeelding.",
"render.cpu_progressive_limits": "Maximaal 1.048.576 pixels en 16.777.216 evaluaties van pixel × sample. Het wijzigen van samples verandert de afbeeldingsgrootte nooit automatisch.",
"render.cpu_progressive_seed": "Seed voor CPU-sampling",
"render.cpu_progressive_spp": "samples per pixel",
diff --git a/assets/locales/ro.json b/assets/locales/ro.json
--- a/assets/locales/ro.json
+++ b/assets/locales/ro.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Până la opt lumini punctiforme, adăugate la iluminarea direcțională selectată. Distanțele sunt în mm; minimul nu trebuie să depășească distanța de referință.",
"render.cpu_primary_samples": "Eșantioane de margine per pixel",
"render.cpu_progressive": "CPU progresiv",
- "render.cpu_progressive_help": "Acumulează cu integratorul CPU existent, în treceri de câte un eșantion primar. Reflexiile selectate necesită un singur eșantion; luminile de arie, dielectricii și anizotropia necesită modul Imagine unică.",
+ "render.cpu_progressive_help": "Acumulează cu integratorul CPU existent, în treceri de câte un eșantion primar. Reflexiile selectate necesită un singur eșantion; dielectricii și anizotropia necesită modul Imagine unică.",
"render.cpu_progressive_limits": "Cel mult 1.048.576 de pixeli și 16.777.216 de evaluări pixel × eșantion. Modificarea eșantioanelor nu redimensionează niciodată automat imaginea.",
"render.cpu_progressive_seed": "Sămânță de eșantionare CPU",
"render.cpu_progressive_spp": "eșantioane per pixel",
diff --git a/assets/locales/ru.json b/assets/locales/ru.json
--- a/assets/locales/ru.json
+++ b/assets/locales/ru.json
@@ -2111,7 +2111,7 @@
"render.cpu_point_help": "До восьми точечных источников света в дополнение к выбранному направленному освещению. Расстояния в мм; минимальное не должно превышать опорное.",
"render.cpu_primary_samples": "Сэмплов краёв на пиксель",
"render.cpu_progressive": "Прогрессивный CPU",
- "render.cpu_progressive_help": "Накопление существующим интегратором CPU проходами по одному основному сэмплу. Выбранные отражения требуют одного сэмпла; площадные источники света, диэлектрики и анизотропия требуют режима «Одно изображение».",
+ "render.cpu_progressive_help": "Накопление существующим интегратором CPU проходами по одному основному сэмплу. Выбранные отражения требуют одного сэмпла; диэлектрики и анизотропия требуют режима «Одно изображение».",
"render.cpu_progressive_limits": "Не более 1 048 576 пикселей и 16 777 216 вычислений «пиксель × сэмпл». Изменение числа сэмплов никогда не меняет размер изображения автоматически.",
"render.cpu_progressive_seed": "Начальное значение сэмплирования CPU",
"render.cpu_progressive_spp": "сэмплов на пиксель",
diff --git a/assets/locales/sr.json b/assets/locales/sr.json
--- a/assets/locales/sr.json
+++ b/assets/locales/sr.json
@@ -2159,7 +2159,7 @@
"render.cpu_point_help": "До осам тачкастих светала, додатих изабраном усмереном осветљењу. Растојања су у mm; минимално не сме премашити референтно растојање.",
"render.cpu_primary_samples": "Узорци ивица по пикселу",
"render.cpu_progressive": "CPU прогресивно",
- "render.cpu_progressive_help": "Акумулира постојећи CPU интегратор у пролазима од по једног примарног узорка. Изабране рефлексије захтевају један узорак; површинска светла, диелектрици и анизотропија захтевају Једна слика.",
+ "render.cpu_progressive_help": "Акумулира постојећи CPU интегратор у пролазима од по једног примарног узорка. Изабране рефлексије захтевају један узорак; диелектрици и анизотропија захтевају Једна слика.",
"render.cpu_progressive_limits": "Највише 1.048.576 пиксела и 16.777.216 израчунавања пиксел × узорак. Промена узорака никад аутоматски не мења величину слике.",
"render.cpu_progressive_seed": "CPU семе узорковања",
"render.cpu_progressive_spp": "узорака по пикселу",
diff --git a/assets/locales/sv.json b/assets/locales/sv.json
--- a/assets/locales/sv.json
+++ b/assets/locales/sv.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Upp till åtta punktljus, som läggs till den valda riktade belysningen. Avstånd anges i mm; minimiavståndet får inte överstiga referensavståndet.",
"render.cpu_primary_samples": "Kantprov per pixel",
"render.cpu_progressive": "Progressiv CPU",
- "render.cpu_progressive_help": "Ackumulera den befintliga CPU-integratorn i omgångar om ett primärt prov. Valda reflektioner kräver ett prov; ytljus, dielektrika och anisotropi kräver Enstaka bild.",
+ "render.cpu_progressive_help": "Ackumulera den befintliga CPU-integratorn i omgångar om ett primärt prov. Valda reflektioner kräver ett prov; dielektrika och anisotropi kräver Enstaka bild.",
"render.cpu_progressive_limits": "Högst 1 048 576 pixlar och 16 777 216 utvärderingar av pixel × prov. Ändrat antal prov ändrar aldrig bildens storlek automatiskt.",
"render.cpu_progressive_seed": "Startvärde för CPU-sampling",
"render.cpu_progressive_spp": "prov per pixel",
diff --git a/assets/locales/tr.json b/assets/locales/tr.json
--- a/assets/locales/tr.json
+++ b/assets/locales/tr.json
@@ -2163,7 +2163,7 @@
"render.cpu_point_help": "Seçili yönlü aydınlatmaya eklenen en fazla sekiz nokta ışık. Mesafeler mm cinsindendir; en az mesafe referans mesafeyi aşmamalıdır.",
"render.cpu_primary_samples": "Piksel başına kenar örneği",
"render.cpu_progressive": "CPU artımlı",
- "render.cpu_progressive_help": "Mevcut CPU tümleştiricisini tek birincil örnekli geçişlerde biriktirin. Seçili yansımalar tek örnek gerektirir; alan ışıkları, dielektrikler ve anizotropi Tek görüntü gerektirir.",
+ "render.cpu_progressive_help": "Mevcut CPU tümleştiricisini tek birincil örnekli geçişlerde biriktirin. Seçili yansımalar tek örnek gerektirir; dielektrikler ve anizotropi Tek görüntü gerektirir.",
"render.cpu_progressive_limits": "En fazla 1.048.576 piksel ve 16.777.216 piksel × örnek değerlendirmesi. Örnekleri değiştirmek görüntüyü asla otomatik yeniden boyutlandırmaz.",
"render.cpu_progressive_seed": "CPU örnekleme tohumu",
"render.cpu_progressive_spp": "piksel başına örnek",
diff --git a/assets/locales/uk.json b/assets/locales/uk.json
--- a/assets/locales/uk.json
+++ b/assets/locales/uk.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "До восьми точкових джерел світла, що додаються до вибраного напрямленого освітлення. Відстані — у мм; мінімум не має перевищувати опорну відстань.",
"render.cpu_primary_samples": "Вибірок краю на піксель",
"render.cpu_progressive": "Прогресивний CPU",
- "render.cpu_progressive_help": "Накопичувати наявний інтегратор CPU проходами по одній первинній вибірці. Вибрані відбиття потребують однієї вибірки; площинні джерела світла, діелектрики та анізотропія потребують режиму «Одне зображення».",
+ "render.cpu_progressive_help": "Накопичувати наявний інтегратор CPU проходами по одній первинній вибірці. Вибрані відбиття потребують однієї вибірки; діелектрики та анізотропія потребують режиму «Одне зображення».",
"render.cpu_progressive_limits": "Не більше 1,048,576 пікселів і 16,777,216 обчислень «піксель × вибірка». Зміна кількості вибірок ніколи не змінює розмір зображення автоматично.",
"render.cpu_progressive_seed": "Початкове значення вибірки CPU",
"render.cpu_progressive_spp": "вибірок на піксель",
diff --git a/assets/locales/vi.json b/assets/locales/vi.json
--- a/assets/locales/vi.json
+++ b/assets/locales/vi.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "Tối đa tám nguồn sáng điểm, được bổ sung vào ánh sáng định hướng đã chọn. Khoảng cách tính bằng mm; khoảng cách tối thiểu không được vượt quá khoảng cách tham chiếu.",
"render.cpu_primary_samples": "Số mẫu cạnh trên mỗi pixel",
"render.cpu_progressive": "Kết xuất lũy tiến trên CPU",
- "render.cpu_progressive_help": "Tích lũy bộ tích phân CPU hiện có theo từng lượt một mẫu chính. Các phản xạ đã chọn yêu cầu một mẫu; nguồn sáng vùng, chất điện môi và bất đẳng hướng yêu cầu chế độ 'Hình ảnh đơn'.",
+ "render.cpu_progressive_help": "Tích lũy bộ tích phân CPU hiện có theo từng lượt một mẫu chính. Các phản xạ đã chọn yêu cầu một mẫu; chất điện môi và bất đẳng hướng yêu cầu chế độ 'Hình ảnh đơn'.",
"render.cpu_progressive_limits": "Tối đa 1.048.576 pixel và tổng số tính toán (pixel × mẫu) không quá 16.777.216. Thay đổi số mẫu không bao giờ tự động đổi kích thước ảnh.",
"render.cpu_progressive_seed": "Hạt giống lấy mẫu CPU",
"render.cpu_progressive_spp": "Mẫu trên mỗi pixel",
diff --git a/assets/locales/zh-TW.json b/assets/locales/zh-TW.json
--- a/assets/locales/zh-TW.json
+++ b/assets/locales/zh-TW.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "最多八個點光源,疊加於所選的方向光照明之上。距離單位為 mm;最小距離不得超過參考距離。",
"render.cpu_primary_samples": "每像素邊緣取樣數",
"render.cpu_progressive": "CPU 漸進式",
- "render.cpu_progressive_help": "以每輪一個主取樣為單位,累積現有的 CPU 積分器。所選反射需使用一個取樣;面光源、介電質和各向異性需使用「單張影像」。",
+ "render.cpu_progressive_help": "以每輪一個主取樣為單位,累積現有的 CPU 積分器。所選反射需使用一個取樣;介電質和各向異性需使用「單張影像」。",
"render.cpu_progressive_limits": "最多 1,048,576 像素和 16,777,216 次像素 × 取樣評估。變更取樣數不會自動調整影像大小。",
"render.cpu_progressive_seed": "CPU 取樣種子",
"render.cpu_progressive_spp": "取樣/像素",
diff --git a/assets/locales/zh.json b/assets/locales/zh.json
--- a/assets/locales/zh.json
+++ b/assets/locales/zh.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "最多可在所选平行光照明基础上添加8个点光源。距离单位为mm,最小距离不得大于参考距离。",
"render.cpu_primary_samples": "每像素边缘采样",
"render.cpu_progressive": "CPU渐进渲染",
- "render.cpu_progressive_help": "使用现有CPU积分器,每轮累积一个主采样。所选反射需要单采样;面光源、电介质和各向异性需要固定图像模式。",
+ "render.cpu_progressive_help": "使用现有CPU积分器,每轮累积一个主采样。所选反射需要单采样;电介质和各向异性需要固定图像模式。",
"render.cpu_progressive_limits": "最多1,048,576像素,像素×采样总数不超过16,777,216。更改采样数不会自动调整图像尺寸。",
"render.cpu_progressive_seed": "CPU采样种子",
"render.cpu_progressive_spp": "每像素采样数",
@@ -0,0 +1,510 @@
--- a/crates/render/src/job/mod.rs
+++ b/crates/render/src/job/mod.rs
@@ -892,13 +892,10 @@
"CPU progressive requires primary_samples: 1 and one reflection attempt per camera sample",
));
}
- if !self.dielectrics.is_empty()
- || self.anisotropic_faces.iter().any(Option::is_some)
- || self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty())
- {
+ if !self.dielectrics.is_empty() || self.anisotropic_faces.iter().any(Option::is_some) {
return Err(JobError::new(
ErrorCode::InvalidRequest,
- "CPU progressive currently supports isotropic opaque surfaces and directional/point/spot lights; use fixed sampling for dielectric, anisotropic or area/LED inputs",
+ "CPU progressive currently supports isotropic opaque surfaces; use fixed sampling for dielectric or anisotropic inputs",
));
}
}
@@ -1187,7 +1184,7 @@
color: [1.0; 3],
intensity: 1.0,
};
- assert!(RaytraceOptions { progressive, area_lights: Some(vec![area]), ..Default::default() }.validate().is_err());
+ RaytraceOptions { progressive, area_lights: Some(vec![area]), ..Default::default() }.validate().unwrap();
let dielectric = DielectricOptions {
body_index: 0,
body_name: "Body".into(),
@@ -1209,4 +1206,76 @@
assert!(RaytraceOptions { progressive, anisotropic_faces: [Some(face), None], ..Default::default() }.validate().is_err());
RaytraceOptions { progressive, area_lights: Some(Vec::new()), ..Default::default() }.validate().unwrap();
}
-}
+
+ #[test]
+ fn progressive_rectangle_and_led_admission_preserves_source_limits_and_atomic_rejection() {
+ let strip = led_strip::LedStripOptions {
+ center_mm: [0.0, 0.0, 10.0],
+ length_mm: 2.0,
+ width_mm: 2.0,
+ length_axis: [1.0, 0.0, 0.0],
+ width_axis: [0.0, -1.0, 0.0],
+ color: [1.0, 0.5, 0.25],
+ intensity: 2.0,
+ };
+ for samples in [1, 4, 16, 64] {
+ let mut options = RaytraceOptions {
+ progressive: Some(CpuProgressiveOptions { samples_per_pixel: 3, seed: 97 }),
+ lights: Some(Vec::new()),
+ ..Default::default()
+ };
+ for count in 1..=4 {
+ strip.append_to(&mut options, samples).unwrap();
+ options.validate().unwrap();
+ assert_eq!(options.area_lights.as_ref().unwrap().len(), count);
+ assert_eq!(
+ options.retained_bytes().unwrap(),
+ (options.area_lights.as_ref().unwrap().capacity() * std::mem::size_of::<RectangleLightOptions>()) as u64
+ );
+ }
+ let before = options.clone();
+ let capacity = options.area_lights.as_ref().unwrap().capacity();
+ assert!(strip.append_to(&mut options, samples).is_err());
+ assert_eq!(options, before);
+ assert_eq!(options.area_lights.as_ref().unwrap().capacity(), capacity);
+ options.area_lights.as_mut().unwrap().pop();
+ let before = options.clone();
+ let conflicting = if samples == 1 { 4 } else { 1 };
+ assert!(strip.append_to(&mut options, conflicting).is_err());
+ assert_eq!(options, before);
+ for invalid in [0, 2, 65, u32::MAX] {
+ let mut hostile = options.clone();
+ hostile.area_samples = Some(invalid);
+ assert!(hostile.validate().is_err());
+ }
+ let mut hostile = options.clone();
+ hostile.area_lights.as_mut().unwrap()[0].intensity = f64::NAN;
+ assert!(hostile.validate().is_err());
+ }
+ let mut maximum = RaytraceOptions {
+ progressive: Some(CpuProgressiveOptions::default()),
+ lights: Some(vec![DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 1.0 }; 8]),
+ punctual_lights: Some(vec![PunctualLightOptions {
+ position_mm: [0.0, 0.0, 10.0],
+ color: [1.0; 3],
+ intensity: 1.0,
+ reference_distance_mm: 10.0,
+ minimum_distance_mm: 0.001,
+ cone: None,
+ }; 8]),
+ ..Default::default()
+ };
+ for _ in 0..4 {
+ strip.append_to(&mut maximum, 16).unwrap();
+ }
+ maximum.validate().unwrap();
+ maximum.area_lights.as_mut().unwrap().push(strip.rectangle().unwrap());
+ assert!(maximum.validate().is_err());
+ for options in [
+ RaytraceOptions { progressive: Some(CpuProgressiveOptions::default()), area_samples: Some(16), ..Default::default() },
+ RaytraceOptions { progressive: Some(CpuProgressiveOptions::default()), seed: Some(7), ..Default::default() },
+ ] {
+ assert!(options.validate().is_err());
+ }
+ }
+}
--- a/crates/render/src/raytrace/primary_aa_tests.rs
+++ b/crates/render/src/raytrace/primary_aa_tests.rs
@@ -489,3 +489,227 @@
assert!(area_sample_key(41, 4, 4, 0).is_err());
assert!(area_sample_key(u64::MAX, 0, 4, 0).is_err());
}
+
+
+// Original receiver and rectangular source; no borrowed model or recorded image.
+fn progressive_area_fixture(area_samples: u32, samples: u32, seed: u32, shadows: bool) -> ImageTask {
+ let original = fixture(1, 1, Some(true));
+ let mut scene = original.scene.clone();
+ scene.meshes[0].mesh = Arc::new(Mesh {
+ positions: vec![Vec3::new(-100.0, -100.0, 0.0), Vec3::new(100.0, -100.0, 0.0), Vec3::new(100.0, 100.0, 0.0), Vec3::new(-100.0, 100.0, 0.0)],
+ normals: vec![Vec3::Z; 4],
+ triangles: vec![[0, 1, 2], [0, 2, 3]],
+ tri_face: vec![0; 2],
+ ..Default::default()
+ });
+ scene.meshes[0].surface = Some(OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), 0.0, 1.0, 1.0).unwrap());
+ scene.bounds = scene.meshes[0].mesh.bounds();
+ let options = RaytraceOptions {
+ progressive: Some(job::CpuProgressiveOptions { samples_per_pixel: samples, seed: 97 }),
+ lights: Some(Vec::new()),
+ area_lights: Some(vec![job::RectangleLightOptions {
+ center_mm: [0.0, 0.0, 10.0],
+ edge_u_mm: [2.0, 0.0, 0.0],
+ edge_v_mm: [0.0, -2.0, 0.0],
+ color: [1.0, 0.5, 0.25],
+ intensity: 2.0,
+ }]),
+ area_samples: Some(area_samples),
+ seed: Some(seed),
+ shadows,
+ ..Default::default()
+ };
+ let request = ImageRequest { raytrace: Some(options.clone()), ..original.request.clone() };
+ ImageTask::new(scene, request, CancelToken::default(), options).unwrap()
+}
+
+fn progressive_area_rebuild(source: &ImageTask, options: RaytraceOptions) -> ImageTask {
+ let request = ImageRequest { raytrace: Some(options.clone()), ..source.request.clone() };
+ ImageTask::new(source.scene.clone(), request, CancelToken::default(), options).unwrap()
+}
+
+#[test]
+fn progressive_area_transport_uses_absolute_film_ordinal_and_seed_for_all_sample_counts() {
+ use super::super::area::RectangleLight;
+ for count in [1, 4, 16, 64] {
+ for seed in [7, 91] {
+ let mut task = progressive_area_fixture(count, 2, seed, false);
+ let light = RectangleLight::new(Vec3::Z * 10.0, Vec3::X * 2.0, -Vec3::Y * 2.0, Vec3::new(1.0, 0.5, 0.25), 2.0).unwrap();
+ let mut inspected = 0;
+ let mut first_positions = Vec::new();
+ let mut complete = false;
+ for _ in 0..200_000 {
+ let ordinal = task.progressive.as_ref().unwrap().film.samples();
+ let before = task.pending.as_ref().filter(|p| p.hit.is_some() && p.area_light == 0 && p.area_index < count).map(|p| (p.area_index, p.ray));
+ let poll = task.step_with_previews(1).unwrap();
+ if let Some((index, ray)) = before
+ && let Some(pending) = task.pending.as_ref()
+ && pending.area_light == 0
+ && pending.area_index == index + 1
+ {
+ let point = ray.origin() + ray.direction() * (-ray.origin().z / ray.direction().z);
+ let key = area_sample_key(0, 0, 1, ordinal).unwrap();
+ let sampled = light.sample(point, key, 0, index, count, seed).unwrap().unwrap();
+ // Index-matched nonmetallic white receiver is exactly Lambertian.
+ // Check the actual consumer, independently of its BRDF and area weight.
+ let delta = sampled.position() - point;
+ let distance_squared = delta.dot(delta);
+ let factor = 4.0 * delta.z * delta.z / (distance_squared * distance_squared * f64::from(count) * std::f64::consts::PI);
+ let expected = Vec3::new(2.0, 1.0, 0.5) * factor;
+ assert!((pending.area_value - expected).len() < 1e-12, "N={count}, seed={seed}, film={ordinal}, index={index}");
+ if index == 0 {
+ first_positions.push(sampled.position());
+ }
+ inspected += 1;
+ }
+ if matches!(poll, ImagePoll::Complete(_)) {
+ complete = true;
+ break;
+ }
+ }
+ assert!(complete);
+ assert_eq!(inspected, 2 * count);
+ assert_eq!(first_positions.len(), 2);
+ if count == 1 {
+ assert_eq!(first_positions[0], first_positions[1], "one sample intentionally stays at the emitter centre");
+ } else {
+ assert_ne!(first_positions[0], first_positions[1], "later film passes must not reuse the first emitter point");
+ }
+ }
+ }
+}
+
+#[test]
+fn progressive_area_center_sample_resolves_the_analytic_linear_mean_once() {
+ let task = progressive_area_fixture(1, 3, 7, false);
+ let camera = task.request.camera;
+ let mut sum = Vec3::ZERO;
+ for ordinal in 0..3 {
+ let [u, v] = progressive::variates(97, 0, ordinal, 0);
+ let (origin, direction) = camera.ray(u, v, 1.0, 1.0);
+ let point = origin + direction * (-origin.z / direction.z);
+ let delta = Vec3::Z * 10.0 - point;
+ let r2 = delta.dot(delta);
+ sum += Vec3::new(2.0, 1.0, 0.5) * (4.0 * delta.z * delta.z / (r2 * r2 * std::f64::consts::PI));
+ }
+ let mean = sum / 3.0;
+ let expected = [mean.x, mean.y, mean.z].map(|value| encode8(value / (1.0 + value)));
+ let (_, png, _) = progressive_complete(task, 1);
+ assert_eq!(::image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0, [expected[0], expected[1], expected[2], 255]);
+}
+
+#[test]
+fn progressive_area_checkpoints_and_work_are_independent_of_target_and_quantum() {
+ for count in [1, 4, 16, 64] {
+ let small = progressive_complete(progressive_area_fixture(count, 5, 23, true), 1);
+ let large = progressive_complete(progressive_area_fixture(count, 5, 23, true), 16_384);
+ assert_eq!(small, large, "area continuation must share the same cumulative work across quanta");
+ assert_eq!(small.0.iter().map(|(samples, _)| *samples).collect::<Vec<_>>(), [1, 2, 4]);
+ for (samples, checkpoint) in &small.0 {
+ let (_, final_png, _) = progressive_complete(progressive_area_fixture(count, *samples, 23, true), 3);
+ assert_eq!(&final_png, checkpoint);
+ }
+ }
+}
+
+#[test]
+fn progressive_led_lowering_keeps_rectangle_power_sampling_and_final_bytes() {
+ let strip = job::led_strip::LedStripOptions {
+ center_mm: [0.0, 0.0, 10.0],
+ length_mm: 2.0,
+ width_mm: 2.0,
+ length_axis: [1.0, 0.0, 0.0],
+ width_axis: [0.0, -1.0, 0.0],
+ color: [1.0, 0.5, 0.25],
+ intensity: 2.0,
+ };
+ for count in [1, 4, 16, 64] {
+ let direct = progressive_area_fixture(count, 3, 7, true);
+ let mut options = direct.options.clone();
+ options.area_lights = None;
+ options.area_samples = None;
+ options.seed = None;
+ strip.append_to(&mut options, count).unwrap();
+ options.seed = direct.options.seed;
+ assert_eq!(options, direct.options);
+ let lowered = progressive_area_rebuild(&direct, options);
+ assert_eq!(progressive_complete(direct, 1), progressive_complete(lowered, 7));
+ }
+}
+
+#[test]
+fn progressive_area_admission_bills_source_capacity_and_reuses_bounded_scratch() {
+ let source = progressive_area_fixture(1, 3, 7, true);
+ let mut empty = source.options.clone();
+ empty.area_lights = None;
+ empty.area_samples = None;
+ empty.seed = None;
+ let baseline = progressive_area_rebuild(&source, empty);
+ let slots = source.options.area_lights.as_ref().unwrap().capacity();
+ let bytes = (slots * std::mem::size_of::<job::RectangleLightOptions>()) as u64;
+ assert_eq!(source.options.retained_bytes().unwrap(), bytes);
+ assert_eq!(source.admission.estimated_total_bytes - baseline.admission.estimated_total_bytes, bytes);
+ assert_eq!(source.admission.preparation_bytes - baseline.admission.preparation_bytes, bytes);
+ for count in [4, 16, 64] {
+ let more = progressive_area_fixture(count, 3, 7, true);
+ assert_eq!(more.admission.estimated_total_bytes, source.admission.estimated_total_bytes, "area samples reuse the same continuation, not an N-sized buffer");
+ }
+ let mut over = source.request.clone();
+ over.width = 1025;
+ over.height = 1024;
+ assert_eq!(admit(&source.scene, &over, &source.options).err().unwrap().code, ErrorCode::LimitExceeded);
+}
+
+#[test]
+fn progressive_area_shadow_cancellation_and_checkpoint_do_not_reset_work() {
+ let mut task = progressive_area_fixture(16, 3, 7, true);
+ let mut pending_shadow = false;
+ for _ in 0..200_000 {
+ if task.pending.as_ref().is_some_and(|p| p.area_shadow) {
+ pending_shadow = true;
+ break;
+ }
+ assert!(matches!(task.step_with_previews(1).unwrap(), ImagePoll::Pending(_)));
+ }
+ assert!(pending_shadow);
+ let used = task.work.used();
+ task.cancel.cancel();
+ assert_eq!(task.step_with_previews(1).err().unwrap().code, ErrorCode::Cancelled);
+ assert_eq!(task.work.used(), used);
+ assert_eq!(task.progressive.as_ref().unwrap().film.samples(), 0);
+ assert!(!task.done);
+
+ let mut reference = progressive_area_fixture(16, 3, 7, true);
+ let mut checkpoint_work = None;
+ for _ in 0..200_000 {
+ if let ImagePoll::Preview { samples_per_pixel, .. } = reference.step_with_previews(1).unwrap() {
+ assert_eq!(samples_per_pixel, 1);
+ checkpoint_work = Some(reference.work.used());
+ break;
+ }
+ }
+ let limit = checkpoint_work.unwrap();
+ let mut bounded = progressive_area_fixture(16, 3, 7, true);
+ bounded.work = WorkBudget::new(limit).unwrap();
+ let mut observed_checkpoint = false;
+ for _ in 0..200_000 {
+ match bounded.step_with_previews(1) {
+ Ok(ImagePoll::Pending(_)) => {}
+ Ok(ImagePoll::Preview { samples_per_pixel, .. }) => {
+ assert!(!observed_checkpoint);
+ assert_eq!(samples_per_pixel, 1);
+ observed_checkpoint = true;
+ assert_eq!(bounded.work.used(), limit);
+ }
+ Ok(ImagePoll::Complete(_)) => panic!("work exhausted after the checkpoint must not publish a final"),
+ Err(error) => {
+ assert_eq!(error.code, ErrorCode::LimitExceeded);
+ assert!(observed_checkpoint);
+ assert_eq!(bounded.work.used(), limit);
+ assert!(!bounded.done);
+ return;
+ }
+ }
+ }
+ panic!("area work ceiling was reset or the fixture did not finish");
+}
--- a/crates/render/src/job/host_tests.rs
+++ b/crates/render/src/job/host_tests.rs
@@ -46,13 +46,17 @@
#[test]
fn preview_refuses_incompatible_transport_and_invalid_input_without_erasing_it() {
let mut source = cpu();
- source.raytrace.as_mut().unwrap().area_lights = Some(vec![crate::job::RectangleLightOptions {
- center_mm: [0.0, 0.0, 10.0],
- edge_u_mm: [2.0, 0.0, 0.0],
- edge_v_mm: [0.0, 2.0, 0.0],
- color: [1.0; 3],
- intensity: 2.0,
- }]);
+ // A valid fixed thin sheet remains deliberately unsupported by progressive.
+ // Area/LED now has its positive preservation case below.
+ source.raytrace.as_mut().unwrap().dielectrics = vec![crate::job::DielectricOptions {
+ body_index: 0,
+ body_name: "Original sheet".into(),
+ boundary: crate::job::DielectricBoundary::ThinSheet,
+ ior: 1.5,
+ absorption_per_mm: [0.0; 3],
+ thickness_mm: 1.0,
+ }];
+ source.raytrace.as_ref().unwrap().validate().unwrap();
let before = serde_json::to_value(&source).unwrap();
assert!(cpu_request(&source).is_err());
assert_eq!(serde_json::to_value(&source).unwrap(), before);
@@ -63,6 +67,84 @@
ImageRequest { raytrace: Some(RaytraceOptions { primary_samples: 3, ..Default::default() }), ..cpu() },
] {
assert!(cpu_request(&source).is_err());
+ }
+ }
+
+
+ #[test]
+ fn preview_preserves_rectangle_and_led_sources_and_all_nonsampling_options() {
+ use crate::job::{
+ CpuProgressiveOptions, DirectionalLightOptions, PunctualLightOptions, RaytraceEnvironmentOptions,
+ RectangleLightOptions, ReflectionBody, ReflectionMode, ReflectionOptions, led_strip::LedStripOptions,
+ };
+ let rectangle = RectangleLightOptions {
+ center_mm: [0.0, 0.0, 10.0],
+ edge_u_mm: [2.0, 0.0, 0.0],
+ edge_v_mm: [0.0, -2.0, 0.0],
+ color: [1.0, 0.5, 0.25],
+ intensity: 2.0,
+ };
+ let led = LedStripOptions {
+ center_mm: [5.0, 0.0, 10.0],
+ length_mm: 8.0,
+ width_mm: 0.5,
+ length_axis: [1.0, 0.0, 0.0],
+ width_axis: [0.0, -1.0, 0.0],
+ color: [0.25, 0.5, 1.0],
+ intensity: 3.0,
+ };
+ for area_samples in [1, 4, 16, 64] {
+ let mut source = cpu();
+ source.width = 3840;
+ source.height = 2160;
+ source.camera.roll = 0.37;
+ source.background = [19, 31, 97];
+ let mut options = RaytraceOptions {
+ primary_samples: 16,
+ exposure_ev: 1.25,
+ shadows: false,
+ lights: Some(vec![DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 1.0 }]),
+ punctual_lights: Some(vec![PunctualLightOptions {
+ position_mm: [0.0, 0.0, 20.0],
+ color: [1.0; 3],
+ intensity: 2.0,
+ reference_distance_mm: 10.0,
+ minimum_distance_mm: 0.01,
+ cone: None,
+ }]),
+ area_lights: Some(vec![rectangle.clone()]),
+ area_samples: Some(area_samples),
+ seed: Some(73),
+ environment: Some(RaytraceEnvironmentOptions { color: [0.2, 0.3, 0.4], intensity: 2.0 }),
+ reflections: Some(ReflectionOptions {
+ bodies: vec![ReflectionBody { body: "Subject".into(), mode: ReflectionMode::RoughSpecular }],
+ samples: 16,
+ max_bounces: 3,
+ }),
+ ..Default::default()
+ };
+ led.append_to(&mut options, area_samples).unwrap();
+ options.validate().unwrap();
+ source.raytrace = Some(options);
+ let before = serde_json::to_value(&source).unwrap();
+ let mut expected = source.clone();
+ expected.width = 1024;
+ expected.height = 576;
+ let expected_options = expected.raytrace.as_mut().unwrap();
+ expected_options.primary_samples = 1;
+ expected_options.progressive = Some(CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 });
+ expected_options.reflections.as_mut().unwrap().samples = 1;
+
+ let preview = cpu_request(&source).unwrap();
+ assert_eq!(serde_json::to_value(&preview).unwrap(), serde_json::to_value(&expected).unwrap(),
+ "all request fields must survive except the explicitly chosen preview envelope");
+ assert_eq!(preview.raytrace, expected.raytrace);
+ assert!(preview.environment.is_none() && preview.textures.is_none());
+ let preview_options = preview.raytrace.as_ref().unwrap();
+ assert_eq!(preview_options.area_lights.as_ref().unwrap().as_slice(), &[rectangle.clone(), led.rectangle().unwrap()]);
+ assert_eq!(preview_options.area_samples, Some(area_samples));
+ assert_eq!(preview_options.seed, Some(73), "camera seed zero must not replace the area-pattern seed");
+ assert_eq!(serde_json::to_value(&source).unwrap(), before, "the final request remains immutable");
}
}
--- a/docs/render-cpu-progressive.md
+++ b/docs/render-cpu-progressive.md
@@ -73,12 +73,20 @@
| Directional / point / spot sources | Existing CPU light contracts and limits |
| Constant / captured HDR environment | Existing CPU contracts; HDR plus rough reflections still requires certified fully metallic selected bodies and effective faces |
| Surface emission / isotropic surface effects | Existing CPU appearance contracts; emission is visible radiance |
-| Area / LED sources | Explicitly rejected in this initial progressive mode |
+| Area / LED sources | Existing bounded rectangle direct-light transport; continuous LED recipes lower to the same sources |
| Dielectric / anisotropic face overrides | Explicitly rejected in this initial progressive mode |
| Output | Opaque PNG with the existing display background |
An incompatible request fails; it does not remove settings or switch methods.
The fixed CPU route retains its existing area, dielectric and anisotropy support.
+Progressive area/LED uses at most four shared rectangle slots, with
+`area_samples` of 1/4/16/64 (default 16) and the existing combined light caps.
+Each film pass uses the existing area-power normalization and cumulative work
+budget; increasing emitter samples does not multiply emitted power. The area
+pattern uses `raytrace.seed`, separately from the camera-jitter seed
+`raytrace.progressive.seed`. One emitter sample still uses its centre. This is
+direct lighting, with no MIS or diffuse GI; general glass and anisotropic
+progressive transport remain unsupported. Host-pool and profile limits are unchanged.
At 1024 × 1024 the camera-sample cap allows at most 16 samples per pixel; at
512 × 512 it allows 64. Requests are also subject to the existing geometry,
384 MiB CPU job and 512 MiB shared host-pool admission limits. Traversal,
--- a/docs/render-settings.md
+++ b/docs/render-settings.md
@@ -162,9 +162,10 @@
empty object defaults to 16 samples and seed 0. Samples are 1..64 and the seed is
u32. `primary_samples` must be 1, output is at most 1,048,576 pixels, and
`width × height × samples_per_pixel` is at most 16,777,216. Saving checks these
-limits without a render host. Area/LED lights, dielectric and anisotropic-face
-overrides are refused in this mode; the fixed CPU route retains them. Embedded
-HDR and one setup exposure remain compatible. See
+limits without a render host. Area/LED direct lights retain the existing four
+shared rectangle slots, emitter-sample counts and power normalization. Dielectric
+and anisotropic-face overrides remain refused; the fixed CPU route retains them.
+Embedded HDR and one setup exposure remain compatible. See
[CPU progressive sampling](render-cpu-progressive.md) for reflection limits,
work admission and provisional/final publication. The top-level runtime
`progressive: true` continues to select GPU.
--- a/docs/render-interactive.md
+++ b/docs/render-interactive.md
@@ -14,6 +14,9 @@
(or one pixel for an extreme aspect). Transparent output, another integrator or
unsupported progressive transport returns an error instead of dropping effects.
The existing profile and host still enforce geometry, traversal and memory limits.
+Rectangle/LED direct-light inputs, their emitter-sample count and area-pattern
+seed survive preview lowering unchanged. Only camera/reflection sampling uses
+the preview envelope; dielectric and anisotropic overrides still fail explicitly.
## Adapter contract
--- a/crates/engine/src/cmd/render.rs
+++ b/crates/engine/src/cmd/render.rs
@@ -124,7 +124,7 @@
CommandSpec::new("render.settings.capture_hdr", "Save Render Settings with HDR", resources::capture_settings).icon("settings").doc_only()
.params("name, set: typed CPU/GPU setup patch with hdr/environment unset; path: scoped HDR; expected_sha256?: digest returned by render.hdr.inspect (changed sources refuse); rotation_rad?:0, intensity?:1; samples?:1/4/16/64 for CPU only (default16); assume_linear_srgb?:false for GPU only. Captures immutable bytes and saves resource plus setup atomically in one undo, without a host; returns name/setup/resource metadata, never source bytes"),
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only()
- .params("setup?: saved name OR settings?: full RenderSetup. A typed job selects raster/cpu_raytrace/gpu_path and its numerical options plus embedded HDR before dispatch; only replace and transient studio_weights may accompany it. Without job, the legacy runtime controls below remain available. See docs/render-settings.md. studio_weights?: three finite 0..1 visibility weights for raster or implicit GPU studio sources only; never saved. integrator?: raster (default) or raytrace; progressive?: false (registered GPU path, CPU integrator/LED options are mutually exclusive); samples?: 1..4096; max_depth?: 1..16; seed?: u32 for progressive; raytrace?: bounded runtime progressive/primary_samples/lights/punctual_lights/area_lights/area_samples/seed/exposure_ev/shadows/reflections/anisotropic_faces/emissions/surface_effects/dielectrics/environment options, only with raytrace. CPU raytrace.budget?: compact (legacy default), studio or workstation; see docs/render-cpu-resource-profiles.md for explicit image/geometry/memory/work limits. CPU raytrace.progressive?: {samples_per_pixel:1..profile limit, seed:u32}; requires primary_samples:1 and selected reflections.samples:1; no area/LED, anisotropy or dielectrics in this initial mode. See docs/render-cpu-progressive.md; environment?: {path: scoped HDR, expected_sha256?: inspected digest, rotation_radians: 0, intensity: 1, samples: 16} OR {resource: saved HDR digest, rotation_radians, intensity, samples}; embedded resources cannot combine with path/hash; explicit sRGB/D65 HDR primaries required. Opaque CPU preview with selected mirror/rough-specular reflections and certified planar anisotropy. HDR with rough reflections or anisotropy requires fully metallic selected bodies and all effective faces; unsupported combinations refuse. No MIS or GI. CPU runtime dielectrics support only certified thin_sheet/closed_box boundaries with explicit IOR and Beer absorption; see docs/render-raytrace-dielectrics.md. CPU emissions are visible radiance only; surface_effects change roughness/shading normals without geometry changes. led_strip?: continuous world-mm LED rectangle recipe; led_samples?: 1/4/16/64 for the whole strip, default16. At most8directional+8punctual+4rectangle/LED sources; shared area N. See docs/render-light-union.md. transparent_background?: false; replace?: false. With progressive:true, raytrace instead accepts GPU dielectrics/clear_coats/textures; environment instead accepts tagged hdr/embedded_hdr/sky with rotation_rad/intensity; file hdr supports expected_sha256, embedded_hdr uses a document resource digest. See docs/render-hdr-resources.md. GPU lens, studio lighting, tone_map, denoise, bounded finishing/backdrop and linear_exr retention are also available. GPU adaptive?: {pilot_samples:16..256,min_samples:16..samples,relative_error:0..1,absolute_error:0.000001..1000000,coverage_error:0.001..0.25} allocates independent production budgets; pilots are discarded, not a certified error guarantee. transmission_samples?:1|2|4 stratifies the first Fresnel branch with extra path cost. volume?:{min_mm:[x,y,z],max_mm:[x,y,z],extinction_per_mm:0..1000,scattering_albedo:[r,g,b],anisotropy:-0.95..0.95} is one finite homogeneous world-mm medium; active media require explicit lighting/environment and refuse glass, denoise and multiple transmission strata. These fields also persist in GPU job.options. See docs/render-materials/TRANSPORT-MASTER-BATCH-2026-10-23.md; see docs/render-transmission.md, render-environment.md, render-runtime-textures.md, render-thin-lens.md, render-studio-lighting.md and render-denoise.md. Optional CPU raytracing requires a raytrace-cpu build and native host, checked before scene capture. Stale geometry is rejected. See docs/render-jobs.md"),
+ .params("setup?: saved name OR settings?: full RenderSetup. A typed job selects raster/cpu_raytrace/gpu_path and its numerical options plus embedded HDR before dispatch; only replace and transient studio_weights may accompany it. Without job, the legacy runtime controls below remain available. See docs/render-settings.md. studio_weights?: three finite 0..1 visibility weights for raster or implicit GPU studio sources only; never saved. integrator?: raster (default) or raytrace; progressive?: false (registered GPU path, CPU integrator/LED options are mutually exclusive); samples?: 1..4096; max_depth?: 1..16; seed?: u32 for progressive; raytrace?: bounded runtime progressive/primary_samples/lights/punctual_lights/area_lights/area_samples/seed/exposure_ev/shadows/reflections/anisotropic_faces/emissions/surface_effects/dielectrics/environment options, only with raytrace. CPU raytrace.budget?: compact (legacy default), studio or workstation; see docs/render-cpu-resource-profiles.md for explicit image/geometry/memory/work limits. CPU raytrace.progressive?: {samples_per_pixel:1..profile limit, seed:u32}; requires primary_samples:1 and selected reflections.samples:1; bounded area/LED direct lights retain their existing source and emitter-sample limits; anisotropy and dielectrics require fixed sampling. See docs/render-cpu-progressive.md; environment?: {path: scoped HDR, expected_sha256?: inspected digest, rotation_radians: 0, intensity: 1, samples: 16} OR {resource: saved HDR digest, rotation_radians, intensity, samples}; embedded resources cannot combine with path/hash; explicit sRGB/D65 HDR primaries required. Opaque CPU preview with selected mirror/rough-specular reflections and certified planar anisotropy. HDR with rough reflections or anisotropy requires fully metallic selected bodies and all effective faces; unsupported combinations refuse. No MIS or GI. CPU runtime dielectrics support only certified thin_sheet/closed_box boundaries with explicit IOR and Beer absorption; see docs/render-raytrace-dielectrics.md. CPU emissions are visible radiance only; surface_effects change roughness/shading normals without geometry changes. led_strip?: continuous world-mm LED rectangle recipe; led_samples?: 1/4/16/64 for the whole strip, default16. At most8directional+8punctual+4rectangle/LED sources; shared area N. See docs/render-light-union.md. transparent_background?: false; replace?: false. With progressive:true, raytrace instead accepts GPU dielectrics/clear_coats/textures; environment instead accepts tagged hdr/embedded_hdr/sky with rotation_rad/intensity; file hdr supports expected_sha256, embedded_hdr uses a document resource digest. See docs/render-hdr-resources.md. GPU lens, studio lighting, tone_map, denoise, bounded finishing/backdrop and linear_exr retention are also available. GPU adaptive?: {pilot_samples:16..256,min_samples:16..samples,relative_error:0..1,absolute_error:0.000001..1000000,coverage_error:0.001..0.25} allocates independent production budgets; pilots are discarded, not a certified error guarantee. transmission_samples?:1|2|4 stratifies the first Fresnel branch with extra path cost. volume?:{min_mm:[x,y,z],max_mm:[x,y,z],extinction_per_mm:0..1000,scattering_albedo:[r,g,b],anisotropy:-0.95..0.95} is one finite homogeneous world-mm medium; active media require explicit lighting/environment and refuse glass, denoise and multiple transmission strata. These fields also persist in GPU job.options. See docs/render-materials/TRANSPORT-MASTER-BATCH-2026-10-23.md; see docs/render-transmission.md, render-environment.md, render-runtime-textures.md, render-thin-lens.md, render-studio-lighting.md and render-denoise.md. Optional CPU raytracing requires a raytrace-cpu build and native host, checked before scene capture. Stale geometry is rejected. See docs/render-jobs.md"),
CommandSpec::new("render.start", "Render Image", jobs::start).key("R").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()
@@ -0,0 +1,300 @@
diff --git a/assets/locales/ar.json b/assets/locales/ar.json
--- a/assets/locales/ar.json
+++ b/assets/locales/ar.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "حتى ثمانية أضواء نقطية تُضاف إلى الإضاءة الاتجاهية المختارة. المسافات بالمليمتر؛ ويجب ألا يتجاوز الحد الأدنى المسافة المرجعية.",
"render.cpu_primary_samples": "عيّنات الحواف لكل بكسل",
"render.cpu_progressive": "CPU تدريجي",
- "render.cpu_progressive_help": "يُراكم مُكامل CPU الحالي على تمريرات من عيّنة أساسية واحدة. الانعكاسات المختارة تتطلب عيّنة واحدة؛ والأضواء المساحية والعوازل وتباين الخواص تتطلب «صورة واحدة».",
+ "render.cpu_progressive_help": "يُراكم مُكامل CPU الحالي على تمريرات من عيّنة أساسية واحدة. الانعكاسات المختارة تتطلب عيّنة واحدة؛ والعوازل وتباين الخواص تتطلب «صورة واحدة».",
"render.cpu_progressive_limits": "بحد أقصى 1,048,576 بكسل و16,777,216 عملية تقييم بكسل × عيّنة. تغيير العيّنات لا يغيّر حجم الصورة تلقائيًا أبدًا.",
"render.cpu_progressive_seed": "بذرة عيّنات CPU",
"render.cpu_progressive_spp": "عيّنات لكل بكسل",
diff --git a/assets/locales/bs.json b/assets/locales/bs.json
--- a/assets/locales/bs.json
+++ b/assets/locales/bs.json
@@ -2159,7 +2159,7 @@
"render.cpu_point_help": "Do osam tačkastih svjetala, dodanih izabranom usmjerenom osvjetljenju. Udaljenosti su u mm; minimalna ne smije premašiti referentnu udaljenost.",
"render.cpu_primary_samples": "Uzorci ivica po pikselu",
"render.cpu_progressive": "CPU progresivno",
- "render.cpu_progressive_help": "Akumulira postojeći CPU integrator u prolazima od po jednog primarnog uzorka. Izabrane refleksije zahtijevaju jedan uzorak; površinska svjetla, dielektrici i anizotropija zahtijevaju Jedna slika.",
+ "render.cpu_progressive_help": "Akumulira postojeći CPU integrator u prolazima od po jednog primarnog uzorka. Izabrane refleksije zahtijevaju jedan uzorak; dielektrici i anizotropija zahtijevaju Jedna slika.",
"render.cpu_progressive_limits": "Najviše 1.048.576 piksela i 16.777.216 izračunavanja piksel × uzorak. Promjena uzoraka nikad automatski ne mijenja veličinu slike.",
"render.cpu_progressive_seed": "CPU sjeme uzorkovanja",
"render.cpu_progressive_spp": "uzoraka po pikselu",
diff --git a/assets/locales/cs.json b/assets/locales/cs.json
--- a/assets/locales/cs.json
+++ b/assets/locales/cs.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Nejvýše osm bodových světel, přidaných k vybranému směrovému osvětlení. Vzdálenosti jsou v mm; minimum nesmí překročit referenční vzdálenost.",
"render.cpu_primary_samples": "Vzorky hran na pixel",
"render.cpu_progressive": "Progresivní CPU",
- "render.cpu_progressive_help": "Akumuluje stávající integrátor CPU v průchodech po jednom primárním vzorku. Vybrané odrazy vyžadují jeden vzorek; plošná světla, dielektrika a anizotropie vyžadují režim Jeden obrázek.",
+ "render.cpu_progressive_help": "Akumuluje stávající integrátor CPU v průchodech po jednom primárním vzorku. Vybrané odrazy vyžadují jeden vzorek; dielektrika a anizotropie vyžadují režim Jeden obrázek.",
"render.cpu_progressive_limits": "Nejvýše 1 048 576 pixelů a 16 777 216 vyhodnocení pixel × vzorek. Změna počtu vzorků nikdy automaticky nezmění velikost obrázku.",
"render.cpu_progressive_seed": "Náhodné semeno vzorkování CPU",
"render.cpu_progressive_spp": "vzorků na pixel",
diff --git a/assets/locales/en.json b/assets/locales/en.json
--- a/assets/locales/en.json
+++ b/assets/locales/en.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "Up to eight point lights, added to the selected directional lighting. Distances are in mm; the minimum must not exceed the reference distance.",
"render.cpu_primary_samples": "Edge samples per pixel",
"render.cpu_progressive": "CPU progressive",
- "render.cpu_progressive_help": "Accumulate the existing CPU integrator in passes of one primary sample. Selected reflections require one sample; area lights, dielectrics and anisotropy require Single image.",
+ "render.cpu_progressive_help": "Accumulate the existing CPU integrator in passes of one primary sample. Selected reflections require one sample; dielectrics and anisotropy require Single image.",
"render.cpu_progressive_limits": "At most 1,048,576 pixels and 16,777,216 pixel × sample evaluations. Changing samples never resizes the image automatically.",
"render.cpu_progressive_seed": "CPU sampling seed",
"render.cpu_progressive_spp": "samples per pixel",
diff --git a/assets/locales/es.json b/assets/locales/es.json
--- a/assets/locales/es.json
+++ b/assets/locales/es.json
@@ -1954,7 +1954,7 @@
"render.cpu_point_help": "Hasta ocho luces puntuales, añadidas a la iluminación direccional elegida. Las distancias están en mm; la mínima no puede superar la de referencia.",
"render.cpu_primary_samples": "Muestras de borde por píxel",
"render.cpu_progressive": "CPU progresivo",
- "render.cpu_progressive_help": "Acumula el integrador CPU actual en pasadas de una muestra primaria. Los reflejos seleccionados requieren una muestra; luces de área, dieléctricos y anisotropía requieren Imagen fija.",
+ "render.cpu_progressive_help": "Acumula el integrador CPU actual en pasadas de una muestra primaria. Los reflejos seleccionados requieren una muestra; dieléctricos y anisotropía requieren Imagen fija.",
"render.cpu_progressive_limits": "Máximo de 1.048.576 píxeles y 16.777.216 evaluaciones de píxel × muestra. Cambiar las muestras nunca redimensiona la imagen automáticamente.",
"render.cpu_progressive_seed": "Semilla de muestreo CPU",
"render.cpu_progressive_spp": "muestras por píxel",
diff --git a/assets/locales/fr.json b/assets/locales/fr.json
--- a/assets/locales/fr.json
+++ b/assets/locales/fr.json
@@ -2169,7 +2169,7 @@
"render.cpu_point_help": "Jusqu'à huit lumières ponctuelles, ajoutées à l'éclairage directionnel choisi. Les distances sont en mm ; la distance minimale ne doit pas dépasser la distance de référence.",
"render.cpu_primary_samples": "Échantillons de bord par pixel",
"render.cpu_progressive": "CPU progressif",
- "render.cpu_progressive_help": "Accumuler l'intégrateur CPU existant par passes d'un échantillon primaire. Les réflexions sélectionnées exigent un échantillon ; les lumières surfaciques, les diélectriques et l'anisotropie exigent Image unique.",
+ "render.cpu_progressive_help": "Accumuler l'intégrateur CPU existant par passes d'un échantillon primaire. Les réflexions sélectionnées exigent un échantillon ; les diélectriques et l'anisotropie exigent Image unique.",
"render.cpu_progressive_limits": "Au plus 1 048 576 pixels et 16 777 216 évaluations pixel × échantillon. Changer le nombre d'échantillons ne redimensionne jamais l'image automatiquement.",
"render.cpu_progressive_seed": "Graine d'échantillonnage CPU",
"render.cpu_progressive_spp": "échantillons par pixel",
diff --git a/assets/locales/gl.json b/assets/locales/gl.json
--- a/assets/locales/gl.json
+++ b/assets/locales/gl.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Ata oito luces puntuais, engadidas á iluminación direccional seleccionada. As distancias están en mm; a mínima non pode superar a distancia de referencia.",
"render.cpu_primary_samples": "Mostras de bordo por píxel",
"render.cpu_progressive": "Progresivo da CPU",
- "render.cpu_progressive_help": "Acumula o integrador da CPU existente en pasadas dunha mostra primaria. As reflexións seleccionadas requiren unha mostra; as luces de área, os dieléctricos e a anisotropía requiren Imaxe única.",
+ "render.cpu_progressive_help": "Acumula o integrador da CPU existente en pasadas dunha mostra primaria. As reflexións seleccionadas requiren unha mostra; os dieléctricos e a anisotropía requiren Imaxe única.",
"render.cpu_progressive_limits": "Como máximo 1.048.576 píxeles e 16.777.216 avaliacións de píxel × mostra. Cambiar as mostras nunca redimensiona a imaxe automaticamente.",
"render.cpu_progressive_seed": "Semente de mostraxe da CPU",
"render.cpu_progressive_spp": "mostras por píxel",
diff --git a/assets/locales/he.json b/assets/locales/he.json
--- a/assets/locales/he.json
+++ b/assets/locales/he.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "עד שמונה אורות נקודתיים, המתווספים לתאורה הכיוונית שנבחרה. המרחקים במילימטרים; המינימום אינו יכול לעלות על מרחק הייחוס.",
"render.cpu_primary_samples": "דגימות קצה לכל פיקסל",
"render.cpu_progressive": "CPU הדרגתי",
- "render.cpu_progressive_help": "צבירה של אינטגרטור ה־CPU הקיים במעברים של דגימה ראשית אחת. החזרות שנבחרו דורשות דגימה אחת; אורות שטח, דיאלקטריים ואנאיזוטרופיה דורשים תמונה בודדת.",
+ "render.cpu_progressive_help": "צבירה של אינטגרטור ה־CPU הקיים במעברים של דגימה ראשית אחת. החזרות שנבחרו דורשות דגימה אחת; דיאלקטריים ואנאיזוטרופיה דורשים תמונה בודדת.",
"render.cpu_progressive_limits": "לכל היותר 1,048,576 פיקסלים ו־16,777,216 הערכות פיקסל × דגימה. שינוי הדגימות לעולם אינו משנה את גודל התמונה אוטומטית.",
"render.cpu_progressive_seed": "זרע דגימה ב־CPU",
"render.cpu_progressive_spp": "דגימות לכל פיקסל",
diff --git a/assets/locales/hr.json b/assets/locales/hr.json
--- a/assets/locales/hr.json
+++ b/assets/locales/hr.json
@@ -2159,7 +2159,7 @@
"render.cpu_point_help": "Do osam točkastih svjetala, dodanih odabranom usmjerenom osvjetljenju. Udaljenosti su u mm; minimalna ne smije premašiti referentnu udaljenost.",
"render.cpu_primary_samples": "Uzorci rubova po pikselu",
"render.cpu_progressive": "CPU progresivno",
- "render.cpu_progressive_help": "Akumulira postojeći CPU integrator u prolazima od po jednog primarnog uzorka. Odabrane refleksije zahtijevaju jedan uzorak; površinska svjetla, dielektrici i anizotropija zahtijevaju Jedna slika.",
+ "render.cpu_progressive_help": "Akumulira postojeći CPU integrator u prolazima od po jednog primarnog uzorka. Odabrane refleksije zahtijevaju jedan uzorak; dielektrici i anizotropija zahtijevaju Jedna slika.",
"render.cpu_progressive_limits": "Najviše 1.048.576 piksela i 16.777.216 izračuna piksel × uzorak. Promjena uzoraka nikad automatski ne mijenja veličinu slike.",
"render.cpu_progressive_seed": "CPU sjeme uzorkovanja",
"render.cpu_progressive_spp": "uzoraka po pikselu",
diff --git a/assets/locales/hu.json b/assets/locales/hu.json
--- a/assets/locales/hu.json
+++ b/assets/locales/hu.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Legfeljebb nyolc pontfény, amelyek a kijelölt irányított megvilágításhoz adódnak hozzá. A távolságok mm-ben értendők; a minimum nem haladhatja meg a referenciatávolságot.",
"render.cpu_primary_samples": "Élminták pixelenként",
"render.cpu_progressive": "Progresszív CPU",
- "render.cpu_progressive_help": "A meglévő CPU-integrátor eredményeinek felhalmozása egy elsődleges mintából álló menetekben. A kijelölt tükröződésekhez egy minta szükséges; a területi fényekhez, a dielektrikumokhoz és az anizotrópiához az Egyetlen kép mód kell.",
+ "render.cpu_progressive_help": "A meglévő CPU-integrátor eredményeinek felhalmozása egy elsődleges mintából álló menetekben. A kijelölt tükröződésekhez egy minta szükséges; a dielektrikumokhoz és az anizotrópiához az Egyetlen kép mód kell.",
"render.cpu_progressive_limits": "Legfeljebb 1 048 576 képpont és 16 777 216 képpont × minta kiértékelés. A minták módosítása sosem méretezi át automatikusan a képet.",
"render.cpu_progressive_seed": "CPU-mintavételi kezdőérték",
"render.cpu_progressive_spp": "minta pixelenként",
diff --git a/assets/locales/id.json b/assets/locales/id.json
--- a/assets/locales/id.json
+++ b/assets/locales/id.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Hingga delapan lampu titik, ditambahkan ke pencahayaan arah yang dipilih. Jarak dalam mm; minimum tidak boleh melebihi jarak referensi.",
"render.cpu_primary_samples": "Sampel tepi per piksel",
"render.cpu_progressive": "CPU progresif",
- "render.cpu_progressive_help": "Akumulasikan integrator CPU yang ada dalam pass berisi satu sampel primer. Pantulan yang dipilih memerlukan satu sampel; lampu area, dielektrik, dan anisotropi memerlukan Gambar tunggal.",
+ "render.cpu_progressive_help": "Akumulasikan integrator CPU yang ada dalam pass berisi satu sampel primer. Pantulan yang dipilih memerlukan satu sampel; dielektrik dan anisotropi memerlukan Gambar tunggal.",
"render.cpu_progressive_limits": "Paling banyak 1.048.576 piksel dan 16.777.216 evaluasi piksel × sampel. Mengubah sampel tidak pernah mengubah ukuran gambar secara otomatis.",
"render.cpu_progressive_seed": "Seed sampling CPU",
"render.cpu_progressive_spp": "sampel per piksel",
diff --git a/assets/locales/it.json b/assets/locales/it.json
--- a/assets/locales/it.json
+++ b/assets/locales/it.json
@@ -2150,7 +2150,7 @@
"render.cpu_point_help": "Fino a otto luci puntiformi, aggiunte all'illuminazione direzionale selezionata. Le distanze sono in mm; il minimo non deve superare la distanza di riferimento.",
"render.cpu_primary_samples": "Campioni dei bordi per pixel",
"render.cpu_progressive": "CPU progressiva",
- "render.cpu_progressive_help": "Accumula l'integratore CPU esistente in passate di un campione primario. Le riflessioni selezionate richiedono un solo campione; luci d'area, dielettrici e anisotropia richiedono Immagine singola.",
+ "render.cpu_progressive_help": "Accumula l'integratore CPU esistente in passate di un campione primario. Le riflessioni selezionate richiedono un solo campione; dielettrici e anisotropia richiedono Immagine singola.",
"render.cpu_progressive_limits": "Al massimo 1,048,576 pixel e 16,777,216 valutazioni pixel × campione. Cambiare i campioni non ridimensiona mai automaticamente l'immagine.",
"render.cpu_progressive_seed": "Seme di campionamento CPU",
"render.cpu_progressive_spp": "campioni per pixel",
diff --git a/assets/locales/ja.json b/assets/locales/ja.json
--- a/assets/locales/ja.json
+++ b/assets/locales/ja.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "選択した平行光に最大8個の点光源を追加できます。距離はmmで、最小距離は基準距離以下にしてください。",
"render.cpu_primary_samples": "輪郭サンプル数/ピクセル",
"render.cpu_progressive": "CPUプログレッシブ",
- "render.cpu_progressive_help": "既存のCPU積分器を、一次サンプル1個ずつのパスで累積します。選択した反射は1サンプルが必要です。面光源、誘電体、異方性には固定画像を使用してください。",
+ "render.cpu_progressive_help": "既存のCPU積分器を、一次サンプル1個ずつのパスで累積します。選択した反射は1サンプルが必要です。誘電体、異方性には固定画像を使用してください。",
"render.cpu_progressive_limits": "最大1,048,576ピクセル、ピクセル×サンプルの合計は16,777,216以下です。サンプル数を変えても画像サイズは自動変更されません。",
"render.cpu_progressive_seed": "CPUサンプリングシード",
"render.cpu_progressive_spp": "サンプル/ピクセル",
diff --git a/assets/locales/ms.json b/assets/locales/ms.json
--- a/assets/locales/ms.json
+++ b/assets/locales/ms.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Sehingga lapan lampu titik, ditambah pada pencahayaan berarah yang dipilih. Jarak dalam mm; jarak minimum tidak boleh melebihi jarak rujukan.",
"render.cpu_primary_samples": "Sampel tepi setiap piksel",
"render.cpu_progressive": "CPU progresif",
- "render.cpu_progressive_help": "Kumpulkan hasil penyepadu CPU sedia ada dalam pas satu sampel utama. Pantulan yang dipilih memerlukan satu sampel; lampu kawasan, dielektrik dan anisotropi memerlukan Imej tunggal.",
+ "render.cpu_progressive_help": "Kumpulkan hasil penyepadu CPU sedia ada dalam pas satu sampel utama. Pantulan yang dipilih memerlukan satu sampel; dielektrik dan anisotropi memerlukan Imej tunggal.",
"render.cpu_progressive_limits": "Paling banyak 1,048,576 piksel dan 16,777,216 penilaian piksel × sampel. Menukar sampel tidak mengubah saiz imej secara automatik.",
"render.cpu_progressive_seed": "Benih pensampelan CPU",
"render.cpu_progressive_spp": "sampel setiap piksel",
diff --git a/assets/locales/nb.json b/assets/locales/nb.json
--- a/assets/locales/nb.json
+++ b/assets/locales/nb.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Opptil åtte punktlys, lagt til i den valgte retningsbelysningen. Avstander er i mm; minimumsavstanden må ikke overstige referanseavstanden.",
"render.cpu_primary_samples": "Kantprøver per piksel",
"render.cpu_progressive": "Progressiv CPU",
- "render.cpu_progressive_help": "Akkumuler den eksisterende CPU-integratoren i omganger på én primærprøve. Valgte refleksjoner krever én prøve; arealys, dielektriske materialer og anisotropi krever Enkeltbilde.",
+ "render.cpu_progressive_help": "Akkumuler den eksisterende CPU-integratoren i omganger på én primærprøve. Valgte refleksjoner krever én prøve; dielektriske materialer og anisotropi krever Enkeltbilde.",
"render.cpu_progressive_limits": "Maksimalt 1 048 576 piksler og 16 777 216 evalueringer av piksel × prøve. Endring av antall prøver endrer aldri bildestørrelsen automatisk.",
"render.cpu_progressive_seed": "Startverdi for CPU-prøvetaking",
"render.cpu_progressive_spp": "prøver per piksel",
diff --git a/assets/locales/nl.json b/assets/locales/nl.json
--- a/assets/locales/nl.json
+++ b/assets/locales/nl.json
@@ -2148,7 +2148,7 @@
"render.cpu_point_help": "Maximaal acht puntlichten, toegevoegd aan de gekozen richtingsverlichting. Afstanden zijn in mm; het minimum mag de referentieafstand niet overschrijden.",
"render.cpu_primary_samples": "Randsamples per pixel",
"render.cpu_progressive": "CPU progressief",
- "render.cpu_progressive_help": "Accumuleert de bestaande CPU-integrator in passes van één primaire sample. Gekozen reflecties vereisen één sample; vlaklichten, diëlektrica en anisotropie vereisen Enkele afbeelding.",
+ "render.cpu_progressive_help": "Accumuleert de bestaande CPU-integrator in passes van één primaire sample. Gekozen reflecties vereisen één sample; diëlektrica en anisotropie vereisen Enkele afbeelding.",
"render.cpu_progressive_limits": "Maximaal 1.048.576 pixels en 16.777.216 evaluaties van pixel × sample. Het wijzigen van samples verandert de afbeeldingsgrootte nooit automatisch.",
"render.cpu_progressive_seed": "Seed voor CPU-sampling",
"render.cpu_progressive_spp": "samples per pixel",
diff --git a/assets/locales/ro.json b/assets/locales/ro.json
--- a/assets/locales/ro.json
+++ b/assets/locales/ro.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Până la opt lumini punctiforme, adăugate la iluminarea direcțională selectată. Distanțele sunt în mm; minimul nu trebuie să depășească distanța de referință.",
"render.cpu_primary_samples": "Eșantioane de margine per pixel",
"render.cpu_progressive": "CPU progresiv",
- "render.cpu_progressive_help": "Acumulează cu integratorul CPU existent, în treceri de câte un eșantion primar. Reflexiile selectate necesită un singur eșantion; luminile de arie, dielectricii și anizotropia necesită modul Imagine unică.",
+ "render.cpu_progressive_help": "Acumulează cu integratorul CPU existent, în treceri de câte un eșantion primar. Reflexiile selectate necesită un singur eșantion; dielectricii și anizotropia necesită modul Imagine unică.",
"render.cpu_progressive_limits": "Cel mult 1.048.576 de pixeli și 16.777.216 de evaluări pixel × eșantion. Modificarea eșantioanelor nu redimensionează niciodată automat imaginea.",
"render.cpu_progressive_seed": "Sămânță de eșantionare CPU",
"render.cpu_progressive_spp": "eșantioane per pixel",
diff --git a/assets/locales/ru.json b/assets/locales/ru.json
--- a/assets/locales/ru.json
+++ b/assets/locales/ru.json
@@ -2111,7 +2111,7 @@
"render.cpu_point_help": "До восьми точечных источников света в дополнение к выбранному направленному освещению. Расстояния в мм; минимальное не должно превышать опорное.",
"render.cpu_primary_samples": "Сэмплов краёв на пиксель",
"render.cpu_progressive": "Прогрессивный CPU",
- "render.cpu_progressive_help": "Накопление существующим интегратором CPU проходами по одному основному сэмплу. Выбранные отражения требуют одного сэмпла; площадные источники света, диэлектрики и анизотропия требуют режима «Одно изображение».",
+ "render.cpu_progressive_help": "Накопление существующим интегратором CPU проходами по одному основному сэмплу. Выбранные отражения требуют одного сэмпла; диэлектрики и анизотропия требуют режима «Одно изображение».",
"render.cpu_progressive_limits": "Не более 1 048 576 пикселей и 16 777 216 вычислений «пиксель × сэмпл». Изменение числа сэмплов никогда не меняет размер изображения автоматически.",
"render.cpu_progressive_seed": "Начальное значение сэмплирования CPU",
"render.cpu_progressive_spp": "сэмплов на пиксель",
diff --git a/assets/locales/sr.json b/assets/locales/sr.json
--- a/assets/locales/sr.json
+++ b/assets/locales/sr.json
@@ -2159,7 +2159,7 @@
"render.cpu_point_help": "До осам тачкастих светала, додатих изабраном усмереном осветљењу. Растојања су у mm; минимално не сме премашити референтно растојање.",
"render.cpu_primary_samples": "Узорци ивица по пикселу",
"render.cpu_progressive": "CPU прогресивно",
- "render.cpu_progressive_help": "Акумулира постојећи CPU интегратор у пролазима од по једног примарног узорка. Изабране рефлексије захтевају један узорак; површинска светла, диелектрици и анизотропија захтевају Једна слика.",
+ "render.cpu_progressive_help": "Акумулира постојећи CPU интегратор у пролазима од по једног примарног узорка. Изабране рефлексије захтевају један узорак; диелектрици и анизотропија захтевају Једна слика.",
"render.cpu_progressive_limits": "Највише 1.048.576 пиксела и 16.777.216 израчунавања пиксел × узорак. Промена узорака никад аутоматски не мења величину слике.",
"render.cpu_progressive_seed": "CPU семе узорковања",
"render.cpu_progressive_spp": "узорака по пикселу",
diff --git a/assets/locales/sv.json b/assets/locales/sv.json
--- a/assets/locales/sv.json
+++ b/assets/locales/sv.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "Upp till åtta punktljus, som läggs till den valda riktade belysningen. Avstånd anges i mm; minimiavståndet får inte överstiga referensavståndet.",
"render.cpu_primary_samples": "Kantprov per pixel",
"render.cpu_progressive": "Progressiv CPU",
- "render.cpu_progressive_help": "Ackumulera den befintliga CPU-integratorn i omgångar om ett primärt prov. Valda reflektioner kräver ett prov; ytljus, dielektrika och anisotropi kräver Enstaka bild.",
+ "render.cpu_progressive_help": "Ackumulera den befintliga CPU-integratorn i omgångar om ett primärt prov. Valda reflektioner kräver ett prov; dielektrika och anisotropi kräver Enstaka bild.",
"render.cpu_progressive_limits": "Högst 1 048 576 pixlar och 16 777 216 utvärderingar av pixel × prov. Ändrat antal prov ändrar aldrig bildens storlek automatiskt.",
"render.cpu_progressive_seed": "Startvärde för CPU-sampling",
"render.cpu_progressive_spp": "prov per pixel",
diff --git a/assets/locales/tr.json b/assets/locales/tr.json
--- a/assets/locales/tr.json
+++ b/assets/locales/tr.json
@@ -2163,7 +2163,7 @@
"render.cpu_point_help": "Seçili yönlü aydınlatmaya eklenen en fazla sekiz nokta ışık. Mesafeler mm cinsindendir; en az mesafe referans mesafeyi aşmamalıdır.",
"render.cpu_primary_samples": "Piksel başına kenar örneği",
"render.cpu_progressive": "CPU artımlı",
- "render.cpu_progressive_help": "Mevcut CPU tümleştiricisini tek birincil örnekli geçişlerde biriktirin. Seçili yansımalar tek örnek gerektirir; alan ışıkları, dielektrikler ve anizotropi Tek görüntü gerektirir.",
+ "render.cpu_progressive_help": "Mevcut CPU tümleştiricisini tek birincil örnekli geçişlerde biriktirin. Seçili yansımalar tek örnek gerektirir; dielektrikler ve anizotropi Tek görüntü gerektirir.",
"render.cpu_progressive_limits": "En fazla 1.048.576 piksel ve 16.777.216 piksel × örnek değerlendirmesi. Örnekleri değiştirmek görüntüyü asla otomatik yeniden boyutlandırmaz.",
"render.cpu_progressive_seed": "CPU örnekleme tohumu",
"render.cpu_progressive_spp": "piksel başına örnek",
diff --git a/assets/locales/uk.json b/assets/locales/uk.json
--- a/assets/locales/uk.json
+++ b/assets/locales/uk.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "До восьми точкових джерел світла, що додаються до вибраного напрямленого освітлення. Відстані — у мм; мінімум не має перевищувати опорну відстань.",
"render.cpu_primary_samples": "Вибірок краю на піксель",
"render.cpu_progressive": "Прогресивний CPU",
- "render.cpu_progressive_help": "Накопичувати наявний інтегратор CPU проходами по одній первинній вибірці. Вибрані відбиття потребують однієї вибірки; площинні джерела світла, діелектрики та анізотропія потребують режиму «Одне зображення».",
+ "render.cpu_progressive_help": "Накопичувати наявний інтегратор CPU проходами по одній первинній вибірці. Вибрані відбиття потребують однієї вибірки; діелектрики та анізотропія потребують режиму «Одне зображення».",
"render.cpu_progressive_limits": "Не більше 1,048,576 пікселів і 16,777,216 обчислень «піксель × вибірка». Зміна кількості вибірок ніколи не змінює розмір зображення автоматично.",
"render.cpu_progressive_seed": "Початкове значення вибірки CPU",
"render.cpu_progressive_spp": "вибірок на піксель",
diff --git a/assets/locales/vi.json b/assets/locales/vi.json
--- a/assets/locales/vi.json
+++ b/assets/locales/vi.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "Tối đa tám nguồn sáng điểm, được bổ sung vào ánh sáng định hướng đã chọn. Khoảng cách tính bằng mm; khoảng cách tối thiểu không được vượt quá khoảng cách tham chiếu.",
"render.cpu_primary_samples": "Số mẫu cạnh trên mỗi pixel",
"render.cpu_progressive": "Kết xuất lũy tiến trên CPU",
- "render.cpu_progressive_help": "Tích lũy bộ tích phân CPU hiện có theo từng lượt một mẫu chính. Các phản xạ đã chọn yêu cầu một mẫu; nguồn sáng vùng, chất điện môi và bất đẳng hướng yêu cầu chế độ 'Hình ảnh đơn'.",
+ "render.cpu_progressive_help": "Tích lũy bộ tích phân CPU hiện có theo từng lượt một mẫu chính. Các phản xạ đã chọn yêu cầu một mẫu; chất điện môi và bất đẳng hướng yêu cầu chế độ 'Hình ảnh đơn'.",
"render.cpu_progressive_limits": "Tối đa 1.048.576 pixel và tổng số tính toán (pixel × mẫu) không quá 16.777.216. Thay đổi số mẫu không bao giờ tự động đổi kích thước ảnh.",
"render.cpu_progressive_seed": "Hạt giống lấy mẫu CPU",
"render.cpu_progressive_spp": "Mẫu trên mỗi pixel",
diff --git a/assets/locales/zh-TW.json b/assets/locales/zh-TW.json
--- a/assets/locales/zh-TW.json
+++ b/assets/locales/zh-TW.json
@@ -2144,7 +2144,7 @@
"render.cpu_point_help": "最多八個點光源,疊加於所選的方向光照明之上。距離單位為 mm;最小距離不得超過參考距離。",
"render.cpu_primary_samples": "每像素邊緣取樣數",
"render.cpu_progressive": "CPU 漸進式",
- "render.cpu_progressive_help": "以每輪一個主取樣為單位,累積現有的 CPU 積分器。所選反射需使用一個取樣;面光源、介電質和各向異性需使用「單張影像」。",
+ "render.cpu_progressive_help": "以每輪一個主取樣為單位,累積現有的 CPU 積分器。所選反射需使用一個取樣;介電質和各向異性需使用「單張影像」。",
"render.cpu_progressive_limits": "最多 1,048,576 像素和 16,777,216 次像素 × 取樣評估。變更取樣數不會自動調整影像大小。",
"render.cpu_progressive_seed": "CPU 取樣種子",
"render.cpu_progressive_spp": "取樣/像素",
diff --git a/assets/locales/zh.json b/assets/locales/zh.json
--- a/assets/locales/zh.json
+++ b/assets/locales/zh.json
@@ -2296,7 +2296,7 @@
"render.cpu_point_help": "最多可在所选平行光照明基础上添加8个点光源。距离单位为mm,最小距离不得大于参考距离。",
"render.cpu_primary_samples": "每像素边缘采样",
"render.cpu_progressive": "CPU渐进渲染",
- "render.cpu_progressive_help": "使用现有CPU积分器,每轮累积一个主采样。所选反射需要单采样;面光源、电介质和各向异性需要固定图像模式。",
+ "render.cpu_progressive_help": "使用现有CPU积分器,每轮累积一个主采样。所选反射需要单采样;电介质和各向异性需要固定图像模式。",
"render.cpu_progressive_limits": "最多1,048,576像素,像素×采样总数不超过16,777,216。更改采样数不会自动调整图像尺寸。",
"render.cpu_progressive_seed": "CPU采样种子",
"render.cpu_progressive_spp": "每像素采样数",
@@ -0,0 +1,226 @@
--- a/crates/engine/src/render_jobs.rs
+++ b/crates/engine/src/render_jobs.rs
@@ -461,53 +461,55 @@
use solidcraft_render::job::interactive::{State, VisualRevision};
use std::time::Duration;
- fn native() -> (crate::Session, CpuHost) {
- let host = CpuHost::for_platform().unwrap();
+ fn native() -> crate::Result<(crate::Session, CpuHost)> {
+ let host = CpuHost::for_platform().map_err(job_error)?;
let mut session = crate::Session::default();
- session.set_render_job_host(Some(host.clone())).unwrap();
- (session, host)
+ session.set_render_job_host(Some(host.clone()))?;
+ Ok((session, host))
}
fn wire(background: [u8; 3]) -> Value {
json!({"integrator":"raytrace","settings":{"width":16,"height":16,"background":background},"raytrace":{"lights":[],"shadows":false}})
}
- fn capture(context: &InteractiveRender, session: &crate::Session, request: &Value) -> Snapshot {
- context.enable(VisualRevision { scene_generation: session.revision, visual_revision: 1 }, Duration::ZERO).unwrap();
- let ticket = context.capture(Duration::from_millis(200)).unwrap().unwrap();
- context.snapshot(session, ticket, request).unwrap()
- }
-
- fn terminal(job: &JobHandle) -> Phase {
+ fn capture(context: &InteractiveRender, session: &crate::Session, request: &Value) -> crate::Result<Snapshot> {
+ context.enable(VisualRevision { scene_generation: session.revision, visual_revision: 1 }, Duration::ZERO)?;
+ let ticket = context
+ .capture(Duration::from_millis(200))?
+ .ok_or_else(|| crate::EngineError::Other("interactive fixture did not produce a capture ticket".into()))?;
+ context.snapshot(session, ticket, request)
+ }
+
+ fn terminal(job: &JobHandle) -> crate::Result<Phase> {
// Completion watchdog follows existing native job tests; no speed assertion.
- job.wait_timeout(Duration::from_secs(60)).unwrap().progress.phase
- }
-
- fn created(session: &crate::Session, ticket: solidcraft_render::job::interactive::CaptureTicket, request: &Value) -> JobHandle {
- let p = crate::cmd::prepare_interactive(session, request).unwrap();
+ Ok(job.wait_timeout(Duration::from_secs(60)).map_err(job_error)?.progress.phase)
+ }
+
+ fn created(session: &crate::Session, ticket: solidcraft_render::job::interactive::CaptureTicket, request: &Value) -> crate::Result<JobHandle> {
+ let p = crate::cmd::prepare_interactive(session, request)?;
session
.render_job_host()
- .unwrap()
+ .ok_or_else(|| crate::EngineError::Other("interactive fixture has no render job host".into()))?
.submit_with_environment_and_options(ticket.job_id(), ticket.revision().scene_generation, p.scene, p.request, p.options, p.environment)
- .unwrap()
+ .map_err(job_error)
}
#[test]
fn registration_is_explicit_and_preview_frames_never_join_final_registry() {
- let (mut session, _) = native();
+ let (mut session, _) = native().unwrap();
let document = session.doc.clone();
let history = (session.revision, session.undo.len(), session.redo.len());
let context = session.interactive_render().unwrap();
assert_eq!(context.state().unwrap(), State::Off);
assert!(context.is_current(&session));
assert!(context.capture(Duration::from_secs(1)).unwrap().is_none());
- let snapshot = capture(&context, &session, &wire([31, 67, 101]));
+ let snapshot = capture(&context, &session, &wire([31, 67, 101])).unwrap();
let ticket = snapshot.ticket();
assert_ne!(ticket.job_id().session, session.render_jobs.owner);
assert_eq!(snapshot.document_uid(), session.uid);
assert!(snapshot.run().unwrap());
let preview = context.current().unwrap().unwrap();
- assert_eq!(terminal(&preview), Phase::Completed);
+ assert_eq!(terminal(&preview).unwrap(), Phase::Completed);
assert!(preview.frame().unwrap().is_some());
assert!(session.render_job_handle(preview.id()).is_err());
assert!(session.execute("render.status", &json!({"job":preview.id()})).is_err());
@@ -520,9 +522,9 @@
#[test]
fn direct_final_start_preempts_only_interactive_and_finishes() {
- let (mut session, _) = native();
+ let (mut session, _) = native().unwrap();
let context = session.interactive_render().unwrap();
- assert!(capture(&context, &session, &wire([11, 29, 47])).run().unwrap());
+ assert!(capture(&context, &session, &wire([11, 29, 47])).unwrap().run().unwrap());
let old = context.current().unwrap().unwrap();
let reply = session.execute("render.start", &wire([83, 107, 139])).unwrap();
let final_id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
@@ -531,8 +533,8 @@
assert_eq!(context.state().unwrap(), State::Paused);
assert!(context.current().unwrap().is_none());
assert!(context.capture(Duration::from_secs(100)).unwrap().is_none());
- assert_eq!(terminal(&final_job), Phase::Completed);
- assert!(matches!(terminal(&old), Phase::Cancelled | Phase::Completed));
+ assert_eq!(terminal(&final_job).unwrap(), Phase::Completed);
+ assert!(matches!(terminal(&old).unwrap(), Phase::Cancelled | Phase::Completed));
assert!(!session.render_final_active().unwrap());
let digest = final_job.frame().unwrap().unwrap().info.png_sha256.clone();
context.disable().unwrap();
@@ -542,12 +544,12 @@
#[test]
fn replacement_context_revokes_old_capture_without_meshing() {
- let (mut session, host) = native();
+ let (mut session, host) = native().unwrap();
session.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Source"})).unwrap();
let world = session.world_state();
assert!(world.body("Source").unwrap().cached_mesh().is_none());
let old = session.interactive_render().unwrap();
- let snapshot = capture(&old, &session, &wire([1, 2, 3]));
+ let snapshot = capture(&old, &session, &wire([1, 2, 3])).unwrap();
let old_owner = snapshot.ticket().job_id().session;
let fresh = session.interactive_render().unwrap();
assert!(!old.is_current(&session));
@@ -556,16 +558,16 @@
assert!(!snapshot.run().unwrap());
assert!(world.body("Source").unwrap().cached_mesh().is_none());
assert_eq!(host.admitted_bytes(), 0);
- let next = capture(&fresh, &session, &wire([4, 5, 6]));
+ let next = capture(&fresh, &session, &wire([4, 5, 6])).unwrap();
assert_ne!(next.ticket().job_id().session, old_owner);
}
#[test]
fn host_a_b_a_cannot_revive_old_registration_in_the_same_document() {
- let (mut session, host_a) = native();
+ let (mut session, host_a) = native().unwrap();
let uid = session.uid;
let old = session.interactive_render().unwrap();
- let snapshot = capture(&old, &session, &wire([1, 2, 3]));
+ let snapshot = capture(&old, &session, &wire([1, 2, 3])).unwrap();
let host_b = CpuHost::for_platform().unwrap();
session.set_render_job_host(Some(host_b)).unwrap();
assert_eq!(session.uid, uid);
@@ -583,9 +585,9 @@
#[test]
fn new_design_and_equal_revision_foreign_session_refuse_old_tickets() {
- let (mut session, host) = native();
+ let (mut session, host) = native().unwrap();
let context = session.interactive_render().unwrap();
- let snapshot = capture(&context, &session, &wire([1, 2, 3]));
+ let snapshot = capture(&context, &session, &wire([1, 2, 3])).unwrap();
let ticket = snapshot.ticket();
let uid = session.uid;
let mut foreign = crate::Session::new((*session.doc).clone());
@@ -604,7 +606,7 @@
#[test]
fn scratch_drop_does_not_revoke_source_and_exhaustion_is_local() {
- let (mut session, _) = native();
+ let (mut session, _) = native().unwrap();
let context = session.interactive_render().unwrap();
drop(session.scratch());
assert!(context.is_current(&session));
@@ -619,9 +621,9 @@
#[test]
fn failed_adoption_with_poisoned_controller_cancels_unpumped_cooperative_record() {
- let (mut session, _) = native();
+ let (mut session, _) = native().unwrap();
let context = session.interactive_render().unwrap();
- let snapshot = capture(&context, &session, &wire([1, 2, 3]));
+ let snapshot = capture(&context, &session, &wire([1, 2, 3])).unwrap();
let ticket = snapshot.ticket();
let p = crate::cmd::prepare_interactive(&session, &wire([1, 2, 3])).unwrap();
let mut request = p.request;
@@ -647,22 +649,22 @@
#[test]
fn rejected_fresh_equal_id_record_preserves_retained_completed_frame() {
- let (mut session, _) = native();
+ let (mut session, _) = native().unwrap();
let context = session.interactive_render().unwrap();
- let snapshot = capture(&context, &session, &wire([17, 31, 59]));
+ let snapshot = capture(&context, &session, &wire([17, 31, 59])).unwrap();
let ticket = snapshot.ticket();
assert!(snapshot.run().unwrap());
let accepted = context.current().unwrap().unwrap();
- assert_eq!(terminal(&accepted), Phase::Completed);
+ assert_eq!(terminal(&accepted).unwrap(), Phase::Completed);
let frame = accepted.frame().unwrap().unwrap();
let digest = frame.info.png_sha256.clone();
- let duplicate = created(&session, ticket, &wire([83, 107, 139]));
+ let duplicate = created(&session, ticket, &wire([83, 107, 139])).unwrap();
assert_eq!(duplicate.id(), accepted.id());
let controller = session.render_jobs.interactive.upgrade().unwrap();
assert!(!adopt_created(&controller, ticket, duplicate.clone()).unwrap());
let phase = duplicate.status().unwrap().progress.phase;
assert!(phase.terminal() || phase == Phase::Cancelling);
- assert!(matches!(terminal(&duplicate), Phase::Cancelled | Phase::Completed));
+ assert!(matches!(terminal(&duplicate).unwrap(), Phase::Cancelled | Phase::Completed));
let retained = context.current().unwrap().unwrap().frame().unwrap().unwrap();
assert!(Arc::ptr_eq(&retained, &frame));
assert_eq!(retained.info.png_sha256, digest);
@@ -674,12 +676,12 @@
#[test]
fn dropping_context_before_worker_start_keeps_the_source_mesh_cold() {
- let (mut session, host) = native();
+ let (mut session, host) = native().unwrap();
session.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Source"})).unwrap();
let world = session.world_state();
assert!(world.body("Source").unwrap().cached_mesh().is_none());
let context = session.interactive_render().unwrap();
- let snapshot = capture(&context, &session, &wire([1, 2, 3]));
+ let snapshot = capture(&context, &session, &wire([1, 2, 3])).unwrap();
drop(context);
assert!(!snapshot.run().unwrap());
assert!(world.body("Source").unwrap().cached_mesh().is_none());
--- a/crates/engine/src/render_interactive.rs
+++ b/crates/engine/src/render_interactive.rs
@@ -382,9 +382,7 @@
assert!(doc.upgrade().is_none());
assert!(weak_model.upgrade().is_none());
// Retained Weak allocation identities cannot be recycled for a new object.
- let mut replacement = Session::default();
- replacement.uid = stamp.uid;
- replacement.revision = stamp.revision;
+ let replacement = Session { uid: stamp.uid, revision: stamp.revision, ..Default::default() };
assert!(!stamp.is_current(&replacement));
}
@@ -0,0 +1,7 @@
# Additional real CPU Interactive EV/HDR tests
Source-only, independently reviewed, NOT APPLIED/FORMATTED/COMPILED/RUN. Appends two original tests to the actual213-test C12 postformatter prefix, resulting in215 declarations. Do not apply both this patch and a whole test-file overlay.
EV0,+1,-1,0 follows real Snapshot/native CPU/admitted job/PNG/cache/upload. A normal-facing index-matched grey Lambert surface with one light has independently calculated display RGB51,72,35,51. Outer EV stays0; actual CPU job carries EV once. Reversal must restore identical PNG bytes. HDR adds accepted-image invalidation by pending intent, failed Resume, explicit Pause during real inspection/correction, actual CPU recovery and retention of that newly recovered frame when paused. Digest is checked on captured wire; no full job-HDR provenance, rotation/radiometry, desktop slider, Undo/Redo or in-place linear regrade claim is made.
Requires two positive real-CPU markers. A feature-disabled/refused branch is not qualification. Existing source remains frozen for C12, so these tests belong to a subsequent coherent validation batch, not its53-case selection.

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