diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b9449908..f62ef04e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -14,6 +14,9 @@ Editable drawing code, native projects and shipped exports are collected under ` | File | Author | Source | Licence | |---|---|---|---| +| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 | +| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 | +| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 | | assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 | | assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index a572a852..787a523b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3579,6 +3579,7 @@ name = "solidcraft-render" version = "0.11.0" dependencies = [ "image", + "png", "serde", "serde_json", "solidcraft-geom", diff --git a/Cargo.toml b/Cargo.toml index 7efd70d4..22d28b57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] } # images image = { version = "0.25", default-features = false, features = ["png", "hdr"] } +png = "0.18.1" # ui egui = "0.36" diff --git a/README.md b/README.md index 04e1deca..9b83162d 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ rebuild with no errors, replayed here at 2× speed. (manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a design, or drop it on the window ([docs/step-import.md](docs/step-import.md)). +- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the + integrated product viewport and CPU preview PNGs. Existing simple colors remain supported. + Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented. + See [opaque surfaces](docs/render-opaque-pbr.md). - **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v) points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight. Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1 @@ -163,6 +167,7 @@ cargo run --release -p solidcraft -- --sample # desktop app with a samp cargo run --release -p solidcraft-cli -- commands # the command registry cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png +cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png cargo run --release -p solidcraft -- part.step # open a STEP file as a new design cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file cargo xtask ci # fmt, clippy, tests, assets, layers, wasm diff --git a/ROADMAP.md b/ROADMAP.md index 7add72c6..13869441 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -89,6 +89,27 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## Render foundation +- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated + product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass + stay unchanged. HDRI, transmission and textures remain separate gates. + See [the consumer and limitations](docs/render-opaque-pbr.md). + +- Combined integration is tracked in #842, with independent scalar libraries in + #863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md). + Render/library consumers use the merged asynchronous picker (#1127). Material-card + UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups. + +- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through + engine commands, the Render panel, CLI and persistent MCP. Immutable captures and + results stay outside persistence/undo; native workers and cooperative browser tasks + share the same work protocol. See [jobs, limits and export](docs/render-jobs.md). + +- R3 adds an overlay-free product scene and the integrated Render workspace, with + runtime GPU host capabilities, per-document drafts and named camera/background + controls. Design keeps its camera and drawing policy. Raster transparency remains + approximate; PBR and environments are later slices. See + [workspace and scene contracts](docs/render-workspace.md). + - R2 adds bounded named render setups in document format 21 and the read/save `render.settings.get/set` commands. Proven render-only changes preserve geometry and prepared world data through undo/redo and checkpoint restore. This slice does @@ -101,7 +122,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. - The initial opaque surface numerical contract for #1004 provides bounded linear color/metallic/roughness/IOR admission and an original tested direct-light BRDF. - It does not enable PBR rendering or change stored appearances. See + The numerical module alone advertises no capabilities; the installed consumer above + provides rendering. See [the model, evidence and remaining consumer gates](docs/render-surface.md). - A bounded cooperative Radiance RGBE input utility is available in L1 render; @@ -110,8 +132,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. - R1 adds renderer-independent backend/profile metadata, strict request resolution and bounded image/scratch admission checks, exposed by the read-only `render.backends` command. It describes the current CPU raster snapshot path without evaluating the model. - Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR, - render jobs and optional engines remain future work; see [the contract](docs/render-backends.md) + The legacy Design snapshot route remains unchanged. Later slices above provide the + workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md) and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620). ## Dimensioning and error reporting diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index 91616255..adcd2de3 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -24,6 +24,7 @@ mod crashes; mod fuzz; mod oracle; mod recipe; +mod render; mod spatial; use std::process::ExitCode; @@ -45,6 +46,7 @@ const USAGE: &str = "usage: solidcraft-cli bench-open [--parse-only] solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json] solidcraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right] + solidcraft-cli render --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS] solidcraft-cli exec [json-params] solidcraft-cli commands solidcraft-cli recipe @@ -63,6 +65,7 @@ fn main() -> ExitCode { Some("eval" | "inspect") => cmd_eval(&args[1..]), Some("script") => cmd_script(&args[1..]), Some("snapshot") => cmd_snapshot(&args[1..]), + Some("render") => render::run(&args[1..]), Some("exec") => cmd_exec(&args[1..]), Some("commands") => { use std::io::Write as _; diff --git a/apps/solidcraft-cli/src/render.rs b/apps/solidcraft-cli/src/render.rs new file mode 100644 index 00000000..0d32ad91 --- /dev/null +++ b/apps/solidcraft-cli/src/render.rs @@ -0,0 +1,79 @@ +//! Complete headless image-job workflow through the same engine commands as the UI/MCP. +use serde_json::json; +use solidcraft_engine::{ + doc::render_settings::RenderSetup, + render::job::{CpuHost, JobId, Phase}, +}; + +pub fn run(args: &[String]) -> Result<(), String> { + let input = args.first().filter(|p| !p.starts_with('-')).ok_or("render requires an input design or script")?; + let mut out = None; + let mut setup = None; + let mut width = None; + let mut height = None; + let mut transparent = false; + let mut overwrite = false; + let mut timeout = 120u64; + let mut i = 1; + while let Some(arg) = args.get(i) { + match arg.as_str() { + "--transparent" => transparent = true, + "--overwrite" => overwrite = true, + "--out" | "--setup" | "--width" | "--height" | "--timeout" => { + i += 1; + let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?; + match arg.as_str() { + "--out" => out = Some(value.clone()), + "--setup" => setup = Some(value.clone()), + "--width" => width = Some(value.parse::().map_err(|_| "invalid width")?), + "--height" => height = Some(value.parse::().map_err(|_| "invalid height")?), + _ => timeout = value.parse::().map_err(|_| "invalid timeout")?, + } + } + _ => return Err(format!("unknown render option: {arg}")), + } + i += 1; + } + if !(1..=3600).contains(&timeout) { + return Err("render timeout must be 1..3600 seconds".into()); + } + let out = out.ok_or("render requires --out FILE.png")?; + let mut session = crate::load(input)?; + session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?; + session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?; + let mut settings = if let Some(name) = setup { + session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")? + } else { + let mut settings = RenderSetup::default(); + if let Some(camera) = session.camera { + settings.camera = camera; + } else { + settings.camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds); + } + settings + }; + if let Some(width) = width { + settings.width = width; + } + if let Some(height) = height { + settings.height = height; + } + let params = json!({"settings":settings,"transparent_background":transparent}); + let preflight = session.execute("render.preflight", ¶ms).map_err(|e| e.to_string())?; + let started = session.execute("render.start", ¶ms).map_err(|e| e.to_string())?; + let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?; + let handle = session.render_job_handle(id).map_err(|e| e.to_string())?; + let status = match handle.wait_timeout(std::time::Duration::from_secs(timeout)) { + Ok(status) => status, + Err(e) => { + let _ = session.execute("render.cancel", &json!({"job":id})); + return Err(e.to_string()); + } + }; + if status.progress.phase != Phase::Completed { + return Err(status.error.map_or_else(|| "render cancelled".into(), |e| e.to_string())); + } + let exported = session.execute("render.export", &json!({"job":id,"path":out,"overwrite":overwrite})).map_err(|e| e.to_string())?; + println!("{}", crate::pretty(&json!({"preflight":preflight,"export":exported}))); + Ok(()) +} diff --git a/assets/locales/de.json b/assets/locales/de.json index dd1396d3..e9764a79 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -788,5 +788,59 @@ "update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.", "update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket.", "cmd.render_settings_get": "Rendereinstellungen", - "cmd.render_settings_set": "Rendereinstellungen speichern" + "cmd.render_settings_set": "Rendereinstellungen speichern", + "cmd.render_scene": "Renderszene prüfen", + "cmd.render_settings_apply": "Renderkamera anwenden", + "toolbar.render": "RENDERN", + "toolbar.render_scene": "SZENE", + "render.preview": "Integrierte Vorschau", + "render.preview_help": "Rasterdarstellung mit gemischter Transparenz. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.", + "render.setup_name": "Name des Setups", + "render.background": "Szenenhintergrund", + "render.perspective": "Perspektivische Kamera", + "render.fit": "Produkt einpassen", + "render.restore_camera": "Gespeicherte Kamera laden", + "render.save_setup": "Setup speichern", + "render.unsaved": "Ungespeicherte Vorschauänderungen", + "render.reload_settings": "Gespeicherte Einstellungen laden", + "render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.", + "render.use_preview": "Integrierte Vorschau verwenden", + "render.navigation_help": "Ziehen zum Drehen; mit mittlerer Taste ziehen zum Verschieben; scrollen zum Zoomen. Setup speichern übernimmt die aktuelle Kamera.", + "render.return_design": "Zurück zu Design", + "render.image_title": "Bild rendern", + "render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz", + "render.image_width": "Breite", + "render.image_height": "Höhe", + "render.image_transparent": "Transparenter Hintergrund", + "render.image_check": "Renderanfrage prüfen", + "render.image_supported": "Renderanfrage unterstützt; Warteschlange und Speicher werden beim Start erneut geprüft.", + "render.image_queued": "Warten auf Rendering", + "render.image_preparing": "Bild wird vorbereitet", + "render.image_running": "Bild wird gerendert", + "render.image_encoding": "PNG wird kodiert", + "render.image_cancelling": "Wird abgebrochen", + "render.image_cancelled": "Rendering abgebrochen", + "render.image_completed": "Bild bereit zum Export", + "render.image_failed": "Rendering fehlgeschlagen", + "render.image_unknown": "Renderstatus nicht verfügbar", + "render.image_reduce": "Kleineres Bild oder einfachere Szene versuchen.", + "render.image_cancel": "Rendering abbrechen", + "render.image_stale": "Dieses Bild verwendet die Szene vor den nachfolgenden Änderungen.", + "render.image_start": "Rendering starten", + "render.image_overwrite": "Vorhandene Datei ersetzen", + "render.image_export": "PNG exportieren", + "cmd.render_preflight": "Renderanfrage prüfen", + "cmd.render_start": "Bild rendern", + "cmd.render_status": "Renderfortschritt", + "cmd.render_cancel": "Rendering abbrechen", + "cmd.render_export": "PNG exportieren", + "toolbar.image": "BILD", + "appearance.surface_response": "Oberflächenverhalten", + "appearance.pbr_opaque": "Opakes PBR", + "appearance.metallic": "Metallanteil", + "appearance.roughness": "Rauheit", + "appearance.dielectric_ior": "Brechungsindex des Dielektrikums", + "appearance.pbr_help": "Diese Oberfläche in Render oder einem Produkt-PNG ansehen. Keine Brechung oder Transmission.", + "render.opaque_preview_help": "Opakes PBR und einfache Farben; Transparenz durch Blending. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.", + "render.opaque_image_summary": "Vorschau-PNG · opakes PBR · Transparenz durch Blending" } diff --git a/assets/locales/en.json b/assets/locales/en.json index e53f0478..af0e10c5 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1046,5 +1046,59 @@ "update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.", "update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead.", "cmd.render_settings_get": "Render Settings", - "cmd.render_settings_set": "Save Render Settings" + "cmd.render_settings_set": "Save Render Settings", + "cmd.render_scene": "Inspect Render Scene", + "cmd.render_settings_apply": "Apply Render Camera", + "toolbar.render": "RENDER", + "toolbar.render_scene": "SCENE", + "render.preview": "Integrated preview", + "render.preview_help": "Raster shading and blended transparency. Refraction, HDRI and final rendering are not available yet.", + "render.setup_name": "Setup name", + "render.background": "Scene background", + "render.perspective": "Perspective camera", + "render.fit": "Fit Product", + "render.restore_camera": "Restore Saved Camera", + "render.save_setup": "Save Setup", + "render.unsaved": "Unsaved preview changes", + "render.reload_settings": "Reload Saved Settings", + "render.unavailable_preview": "This setup requests an unavailable preview profile.", + "render.use_preview": "Use Integrated Preview", + "render.navigation_help": "Drag to orbit; middle-drag to pan; scroll to zoom. Save Setup records the current camera.", + "render.return_design": "Return to Design", + "render.image_title": "Render Image", + "render.image_help": "CPU preview image · PNG · approximate transparency", + "render.image_width": "Width", + "render.image_height": "Height", + "render.image_transparent": "Transparent background", + "render.image_check": "Check Render Request", + "render.image_supported": "Render request is supported; the queue and memory pool are checked again at start.", + "render.image_queued": "Waiting to render", + "render.image_preparing": "Preparing image", + "render.image_running": "Rendering image", + "render.image_encoding": "Encoding PNG", + "render.image_cancelling": "Cancelling", + "render.image_cancelled": "Render cancelled", + "render.image_completed": "Image ready to export", + "render.image_failed": "Render failed", + "render.image_unknown": "Render status unavailable", + "render.image_reduce": "Try a smaller image or simpler scene.", + "render.image_cancel": "Cancel Render", + "render.image_stale": "This image uses the scene captured before subsequent changes.", + "render.image_start": "Start Render", + "render.image_overwrite": "Replace existing file", + "render.image_export": "Export PNG", + "cmd.render_preflight": "Check Render Request", + "cmd.render_start": "Render Image", + "cmd.render_status": "Render Progress", + "cmd.render_cancel": "Cancel Render", + "cmd.render_export": "Export Render Image", + "toolbar.image": "IMAGE", + "appearance.surface_response": "Surface response", + "appearance.pbr_opaque": "Opaque PBR", + "appearance.metallic": "Metallic", + "appearance.roughness": "Roughness", + "appearance.dielectric_ior": "Dielectric IOR", + "appearance.pbr_help": "View this surface in Render or a product PNG. No refraction or transmission.", + "render.opaque_preview_help": "Opaque PBR and simple colors; blended transparency. Refraction, HDRI and final rendering are not available yet.", + "render.opaque_image_summary": "Preview PNG · opaque PBR · blended transparency" } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 3dfdeaa6..0f349f25 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1046,5 +1046,59 @@ "update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。", "update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。", "cmd.render_settings_get": "レンダリング設定", - "cmd.render_settings_set": "レンダリング設定を保存" + "cmd.render_settings_set": "レンダリング設定を保存", + "cmd.render_scene": "レンダリングシーンを確認", + "cmd.render_settings_apply": "レンダリングカメラを適用", + "toolbar.render": "レンダリング", + "toolbar.render_scene": "シーン", + "render.preview": "統合プレビュー", + "render.preview_help": "ラスタシェーディングとブレンド透明度を使用します。屈折、HDRI、最終レンダリングはまだ利用できません。", + "render.setup_name": "セットアップ名", + "render.background": "シーンの背景", + "render.perspective": "透視投影カメラ", + "render.fit": "製品を画面に合わせる", + "render.restore_camera": "保存したカメラを復元", + "render.save_setup": "セットアップを保存", + "render.unsaved": "未保存のプレビュー変更", + "render.reload_settings": "保存した設定を再読み込み", + "render.unavailable_preview": "このセットアップは利用できないプレビュープロファイルを要求しています。", + "render.use_preview": "統合プレビューを使用", + "render.navigation_help": "ドラッグで回転、中央ボタンのドラッグで移動、スクロールでズームします。「セットアップを保存」で現在のカメラを記録します。", + "render.return_design": "設計に戻る", + "render.image_title": "画像をレンダリング", + "render.image_help": "CPUプレビュー画像 · PNG · 近似透過", + "render.image_width": "幅", + "render.image_height": "高さ", + "render.image_transparent": "透明な背景", + "render.image_check": "レンダリング要求を確認", + "render.image_supported": "レンダリング要求に対応しています。開始時にキューとメモリを再確認します。", + "render.image_queued": "レンダリング待機中", + "render.image_preparing": "画像を準備中", + "render.image_running": "画像をレンダリング中", + "render.image_encoding": "PNGをエンコード中", + "render.image_cancelling": "キャンセル中", + "render.image_cancelled": "レンダリングをキャンセルしました", + "render.image_completed": "画像をエクスポートできます", + "render.image_failed": "レンダリングに失敗しました", + "render.image_unknown": "レンダリング状態を取得できません", + "render.image_reduce": "画像を小さくするかシーンを簡略化してください。", + "render.image_cancel": "レンダリングをキャンセル", + "render.image_stale": "この画像は後続の変更前に取得したシーンを使用しています。", + "render.image_start": "レンダリングを開始", + "render.image_overwrite": "既存のファイルを置換", + "render.image_export": "PNGをエクスポート", + "cmd.render_preflight": "レンダリング要求を確認", + "cmd.render_start": "画像をレンダリング", + "cmd.render_status": "レンダリングの進行状況", + "cmd.render_cancel": "レンダリングをキャンセル", + "cmd.render_export": "PNGをエクスポート", + "toolbar.image": "画像", + "appearance.surface_response": "表面の応答", + "appearance.pbr_opaque": "不透明PBR", + "appearance.metallic": "金属度", + "appearance.roughness": "粗さ", + "appearance.dielectric_ior": "誘電体の屈折率", + "appearance.pbr_help": "この表面はレンダリング作業空間または製品PNGで確認できます。屈折や透過には対応していません。", + "render.opaque_preview_help": "不透明PBRと単色表示、アルファ合成による透明表示に対応します。屈折、HDRI、最終レンダリングにはまだ対応していません。", + "render.opaque_image_summary": "プレビューPNG · 不透明PBR · アルファ合成の透明表示" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 8f13da4c..3b6cf556 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -788,5 +788,59 @@ "update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.", "update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다.", "cmd.render_settings_get": "렌더링 설정", - "cmd.render_settings_set": "렌더링 설정 저장" + "cmd.render_settings_set": "렌더링 설정 저장", + "cmd.render_scene": "렌더링 장면 검사", + "cmd.render_settings_apply": "렌더링 카메라 적용", + "toolbar.render": "렌더링", + "toolbar.render_scene": "장면", + "render.preview": "통합 미리보기", + "render.preview_help": "래스터 셰이딩과 블렌딩 투명도를 사용합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.", + "render.setup_name": "설정 이름", + "render.background": "장면 배경", + "render.perspective": "원근 카메라", + "render.fit": "제품에 맞추기", + "render.restore_camera": "저장된 카메라 복원", + "render.save_setup": "설정 저장", + "render.unsaved": "저장하지 않은 미리보기 변경 사항", + "render.reload_settings": "저장된 설정 다시 불러오기", + "render.unavailable_preview": "이 설정은 사용할 수 없는 미리보기 프로필을 요청합니다.", + "render.use_preview": "통합 미리보기 사용", + "render.navigation_help": "드래그하여 회전하고, 가운데 버튼으로 드래그하여 이동하며, 스크롤하여 확대/축소합니다. 설정 저장은 현재 카메라를 기록합니다.", + "render.return_design": "설계로 돌아가기", + "render.image_title": "이미지 렌더링", + "render.image_help": "CPU 미리 보기 이미지 · PNG · 근사 투명도", + "render.image_width": "너비", + "render.image_height": "높이", + "render.image_transparent": "투명 배경", + "render.image_check": "렌더 요청 확인", + "render.image_supported": "지원되는 렌더 요청입니다. 시작 시 대기열과 메모리 풀을 다시 확인합니다.", + "render.image_queued": "렌더링 대기 중", + "render.image_preparing": "이미지 준비 중", + "render.image_running": "이미지 렌더링 중", + "render.image_encoding": "PNG 인코딩 중", + "render.image_cancelling": "취소 중", + "render.image_cancelled": "렌더링 취소됨", + "render.image_completed": "이미지 내보내기 준비됨", + "render.image_failed": "렌더링 실패", + "render.image_unknown": "렌더 상태를 확인할 수 없습니다", + "render.image_reduce": "더 작은 이미지나 더 단순한 장면을 사용해 보세요.", + "render.image_cancel": "렌더링 취소", + "render.image_stale": "이 이미지는 후속 변경 전에 캡처한 장면을 사용합니다.", + "render.image_start": "렌더링 시작", + "render.image_overwrite": "기존 파일 바꾸기", + "render.image_export": "PNG 내보내기", + "cmd.render_preflight": "렌더 요청 확인", + "cmd.render_start": "이미지 렌더링", + "cmd.render_status": "렌더링 진행 상황", + "cmd.render_cancel": "렌더링 취소", + "cmd.render_export": "PNG 내보내기", + "toolbar.image": "이미지", + "appearance.surface_response": "표면 반응", + "appearance.pbr_opaque": "불투명 PBR", + "appearance.metallic": "금속성", + "appearance.roughness": "거칠기", + "appearance.dielectric_ior": "유전체 굴절률", + "appearance.pbr_help": "이 표면은 렌더링 작업 공간이나 제품 PNG에서 확인하세요. 굴절과 투과는 지원하지 않습니다.", + "render.opaque_preview_help": "불투명 PBR 및 단색 표시, 블렌딩 투명도를 지원합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.", + "render.opaque_image_summary": "미리보기 PNG · 불투명 PBR · 블렌딩 투명도" } diff --git a/assets/locales/ru.json b/assets/locales/ru.json index ca8a9314..5a9fc907 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -992,5 +992,59 @@ "sketch_text.on_path": "Текст следует по траектории: кривая задаёт его положение", "cmd.material_properties_validate": "Проверить данные физического материала", "cmd.render_settings_get": "Настройки рендеринга", - "cmd.render_settings_set": "Сохранить настройки рендеринга" + "cmd.render_settings_set": "Сохранить настройки рендеринга", + "cmd.render_scene": "Просмотреть сцену рендеринга", + "cmd.render_settings_apply": "Применить камеру рендеринга", + "toolbar.render": "РЕНДЕРИНГ", + "toolbar.render_scene": "СЦЕНА", + "render.preview": "Встроенный предпросмотр", + "render.preview_help": "Растровое затенение и смешанная прозрачность. Преломление, HDRI и финальный рендеринг пока недоступны.", + "render.setup_name": "Имя набора настроек", + "render.background": "Фон сцены", + "render.perspective": "Перспективная камера", + "render.fit": "Вписать модель", + "render.restore_camera": "Восстановить сохранённую камеру", + "render.save_setup": "Сохранить набор", + "render.unsaved": "Несохранённые изменения предпросмотра", + "render.reload_settings": "Загрузить сохранённые настройки", + "render.unavailable_preview": "Этот набор запрашивает недоступный профиль предпросмотра.", + "render.use_preview": "Использовать встроенный предпросмотр", + "render.navigation_help": "Перетаскивайте для вращения, используйте среднюю кнопку для перемещения и колесо для масштаба. Сохранение набора записывает текущую камеру.", + "render.return_design": "Вернуться к проектированию", + "render.image_title": "Рендеринг изображения", + "render.image_help": "Предпросмотр на CPU · PNG · приближённая прозрачность", + "render.image_width": "Ширина", + "render.image_height": "Высота", + "render.image_transparent": "Прозрачный фон", + "render.image_check": "Проверить запрос рендеринга", + "render.image_supported": "Запрос поддерживается; при запуске очередь и пул памяти будут проверены снова.", + "render.image_queued": "Ожидание рендеринга", + "render.image_preparing": "Подготовка изображения", + "render.image_running": "Рендеринг изображения", + "render.image_encoding": "Кодирование PNG", + "render.image_cancelling": "Отмена", + "render.image_cancelled": "Рендеринг отменён", + "render.image_completed": "Изображение готово к экспорту", + "render.image_failed": "Ошибка рендеринга", + "render.image_unknown": "Состояние рендеринга недоступно", + "render.image_reduce": "Попробуйте уменьшить изображение или упростить сцену.", + "render.image_cancel": "Отменить рендеринг", + "render.image_stale": "Используется сцена, сохранённая до последующих изменений.", + "render.image_start": "Начать рендеринг", + "render.image_overwrite": "Заменить существующий файл", + "render.image_export": "Экспорт PNG", + "cmd.render_preflight": "Проверить запрос рендеринга", + "cmd.render_start": "Рендеринг изображения", + "cmd.render_status": "Ход рендеринга", + "cmd.render_cancel": "Отменить рендеринг", + "cmd.render_export": "Экспортировать изображение рендеринга", + "toolbar.image": "ИЗОБРАЖЕНИЕ", + "appearance.surface_response": "Поведение поверхности", + "appearance.pbr_opaque": "Непрозрачный PBR", + "appearance.metallic": "Металличность", + "appearance.roughness": "Шероховатость", + "appearance.dielectric_ior": "Показатель преломления диэлектрика", + "appearance.pbr_help": "Просмотр поверхности во вкладке Рендер или на PNG изделия. Без преломления и пропускания света.", + "render.opaque_preview_help": "Непрозрачный PBR и простые цвета; прозрачность через смешивание. Преломление, HDRI и финальный рендер пока недоступны.", + "render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание" } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 7d758d80..c183f897 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1046,5 +1046,59 @@ "update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。", "update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。", "cmd.render_settings_get": "渲染设置", - "cmd.render_settings_set": "保存渲染设置" + "cmd.render_settings_set": "保存渲染设置", + "cmd.render_scene": "检查渲染场景", + "cmd.render_settings_apply": "应用渲染相机", + "toolbar.render": "渲染", + "toolbar.render_scene": "场景", + "render.preview": "集成预览", + "render.preview_help": "光栅着色和混合透明度。暂不支持折射、HDRI 和最终渲染。", + "render.setup_name": "设置名称", + "render.background": "场景背景", + "render.perspective": "透视相机", + "render.fit": "适配产品视图", + "render.restore_camera": "恢复已保存相机", + "render.save_setup": "保存设置", + "render.unsaved": "未保存的预览更改", + "render.reload_settings": "重新加载已保存设置", + "render.unavailable_preview": "此设置请求了不可用的预览配置。", + "render.use_preview": "使用集成预览", + "render.navigation_help": "拖动以旋转,按住中键拖动以平移,滚动以缩放。保存设置会记录当前相机。", + "render.return_design": "返回设计", + "render.image_title": "渲染图像", + "render.image_help": "CPU 预览图像 · PNG · 近似透明度", + "render.image_width": "宽度", + "render.image_height": "高度", + "render.image_transparent": "透明背景", + "render.image_check": "检查渲染请求", + "render.image_supported": "支持此渲染请求;开始时将再次检查队列和内存池。", + "render.image_queued": "等待渲染", + "render.image_preparing": "正在准备图像", + "render.image_running": "正在渲染图像", + "render.image_encoding": "正在编码 PNG", + "render.image_cancelling": "正在取消", + "render.image_cancelled": "渲染已取消", + "render.image_completed": "图像可导出", + "render.image_failed": "渲染失败", + "render.image_unknown": "渲染状态不可用", + "render.image_reduce": "请尝试更小的图像或更简单的场景。", + "render.image_cancel": "取消渲染", + "render.image_stale": "此图像使用后续更改之前捕获的场景。", + "render.image_start": "开始渲染", + "render.image_overwrite": "替换现有文件", + "render.image_export": "导出 PNG", + "cmd.render_preflight": "检查渲染请求", + "cmd.render_start": "渲染图像", + "cmd.render_status": "渲染进度", + "cmd.render_cancel": "取消渲染", + "cmd.render_export": "导出 PNG", + "toolbar.image": "图像", + "appearance.surface_response": "表面响应", + "appearance.pbr_opaque": "不透明 PBR", + "appearance.metallic": "金属度", + "appearance.roughness": "粗糙度", + "appearance.dielectric_ior": "电介质折射率", + "appearance.pbr_help": "在渲染工作区或产品 PNG 中查看此表面。不支持折射或透射。", + "render.opaque_preview_help": "不透明 PBR 和纯色;透明效果使用混合。不支持折射、HDRI 和最终渲染。", + "render.opaque_image_summary": "预览 PNG · 不透明 PBR · 混合透明" } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 34f7e3f8..13024d3a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -12,11 +12,34 @@ use crate::Document; /// A look: a name (a library entry's, or "Custom"), sRGB colour and opacity (1 = opaque). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "LookWire")] pub struct Look { pub name: String, pub color: [u8; 3], #[serde(default = "opaque")] pub opacity: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pbr: Option, +} + +#[derive(Deserialize)] +struct LookWire { + name: String, + color: [u8; 3], + #[serde(default = "opaque")] + opacity: f64, + #[serde(default)] + pbr: Option, +} + +impl TryFrom for Look { + type Error = String; + fn try_from(value: LookWire) -> std::result::Result { + if value.pbr.is_some() && value.opacity != 1.0 { + return Err("opaque PBR requires opacity 1; refraction/transmission is not supported".into()); + } + Ok(Self { name: value.name, color: value.color, opacity: value.opacity, pbr: value.pbr }) + } } fn opaque() -> f64 { @@ -25,7 +48,13 @@ fn opaque() -> f64 { impl Look { pub fn custom(color: [u8; 3], opacity: f64) -> Look { - Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0) } + Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None } + } + pub fn opaque_surface(&self) -> crate::Result> { + if self.pbr.is_some() && self.opacity != 1.0 { + return Err(crate::DocError::Invalid("opaque PBR requires opacity 1".into())); + } + self.pbr.map(|p| p.surface(self.color).map_err(|e| crate::DocError::Invalid(e.to_string()))).transpose() } /// Colour as 0–1 fractions. pub fn rgb(&self) -> [f32; 3] { @@ -109,14 +138,19 @@ pub use defaults::LIBRARY; /// A library appearance by name (case-insensitive). pub fn library(name: &str) -> Option { - LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o }) + LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look { + name: n.to_string(), + color: *c, + opacity: *o, + pbr: None, + }) } /// The look a physical material gives a body that has no appearance of its own. pub fn material_look(material: &str) -> Option { let color = crate::material_color(material)?; let opacity = if material == "Glass" { 0.25 } else { 1.0 }; - Some(Look { name: material.to_string(), color, opacity }) + Some(Look { name: material.to_string(), color, opacity, pbr: None }) } impl Document { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 164804c1..fe272ff1 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -164,6 +164,9 @@ //! (`com.example.tool`). Every key is kept as it is, and nothing in it is checked when the //! design opens. A design without it saves as before. 0.11.x refuses format 21, so no //! released build drops the properties when it saves. +//! Under the same version, looks can explicitly select opaque PBR parameters (`pbr`: metal +//! fraction, roughness, dielectric IOR). Absent fields keep the scalar shading a look always +//! had; 0.11.0 asks for an update instead of silently dropping the response. use serde_json::Value; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8c595313..11920c49 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,6 +9,7 @@ pub mod appearance; mod assembly; mod body_transfer; mod body_transfer_sheet; +pub mod pbr_appearance; pub use body_transfer::{valid_transfer_matrix, validate_body_transfer}; pub mod canvas; mod clipboard; diff --git a/crates/doc/src/pbr_appearance.rs b/crates/doc/src/pbr_appearance.rs new file mode 100644 index 00000000..4172f571 --- /dev/null +++ b/crates/doc/src/pbr_appearance.rs @@ -0,0 +1,87 @@ +//! Persistable opaque optical parameters, independent of engineering material data. + +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; +use solidcraft_render::surface::{OpaqueSurface, SurfaceError, srgb8_to_linear}; + +/// Explicitly selected opaque response. Existing scalar looks have no such record. +/// Construction and deserialization share the renderer's finite admission bounds. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "Parameters", into = "Parameters")] +pub struct PbrParameters { + metallic: f64, + roughness: f64, + dielectric_ior: f64, +} + +impl Default for PbrParameters { + fn default() -> Self { + Self { metallic: 0.0, roughness: 0.5, dielectric_ior: 1.5 } + } +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Parameters { + metallic: f64, + roughness: f64, + dielectric_ior: f64, +} + +impl PbrParameters { + pub fn new(metallic: f64, roughness: f64, dielectric_ior: f64) -> Result { + OpaqueSurface::new(Vec3::ZERO, metallic, roughness, dielectric_ior)?; + Ok(Self { metallic, roughness, dielectric_ior }) + } + + /// Decode the containing look's sRGB8 base color once, for the actual consumer. + pub fn surface(self, color: [u8; 3]) -> Result { + let [r, g, b] = color.map(srgb8_to_linear); + OpaqueSurface::new(Vec3::new(r, g, b), self.metallic, self.roughness, self.dielectric_ior) + } + + pub fn metallic(self) -> f64 { + self.metallic + } + pub fn roughness(self) -> f64 { + self.roughness + } + pub fn dielectric_ior(self) -> f64 { + self.dielectric_ior + } +} + +impl TryFrom for PbrParameters { + type Error = SurfaceError; + fn try_from(p: Parameters) -> Result { + Self::new(p.metallic, p.roughness, p.dielectric_ior) + } +} +impl From for Parameters { + fn from(p: PbrParameters) -> Self { + Self { metallic: p.metallic, roughness: p.roughness, dielectric_ior: p.dielectric_ior } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn optical_parameters_share_strict_admission_and_round_trip() { + let p = PbrParameters::new(1.0, 0.3, 1.5).unwrap(); + let saved = serde_json::to_value(p).unwrap(); + assert_eq!(serde_json::from_value::(saved.clone()).unwrap(), p); + for (key, value) in [("metallic", json!(1.1)), ("roughness", json!(0)), ("dielectric_ior", json!(0.9)), ("extra", json!(true))] { + let mut bad = saved.clone(); + bad[key] = value; + assert!(serde_json::from_value::(bad).is_err()); + } + assert!(PbrParameters::new(f64::NAN, 0.3, 1.5).is_err()); + assert!(PbrParameters::new(0.0, f64::INFINITY, 1.5).is_err()); + let surface = p.surface([128, 0, 255]).unwrap(); + assert!((surface.base_color().x - 0.21586050011389926).abs() < 1e-14); + assert_eq!(surface.base_color().z, 1.0); + } +} diff --git a/crates/engine/src/cmd/component_transfer_tests.rs b/crates/engine/src/cmd/component_transfer_tests.rs index 052db71f..a47dd737 100644 --- a/crates/engine/src/cmd/component_transfer_tests.rs +++ b/crates/engine/src/cmd/component_transfer_tests.rs @@ -159,7 +159,10 @@ fn inherited_component_appearance_and_legacy_assignment_survive_transfer() { let mut s = Session::default(); let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); - s.doc_mut().appearances.components.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 }); + s.doc_mut() + .appearances + .components + .insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7, pbr: None }); s.refresh(); let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap(); run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 9d56b7ec..58c08442 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -26,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params( "bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \ appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \ - neither (or color: null): clear, so the face follows its body, the body its component, the component its material", + pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); omitted/null uses legacy scalar shading. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material", ), CommandSpec::new("appearance.library", "Appearance Library", appearance_library) .noundo() @@ -341,7 +341,7 @@ pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> { fn appearance(s: &mut Session, p: &Value) -> Result { use solidcraft_doc::appearance::{FaceLook, Look, library}; let cmd = "appearance.assign"; - let look: Option = match (str_(p, "appearance"), p.get("color")) { + let mut look: Option = match (str_(p, "appearance"), p.get("color")) { // A colour with a name: that colour, called that (a name not in the library is fine). (Some(n), Some(v)) if !v.is_null() => { let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?; @@ -367,6 +367,11 @@ fn appearance(s: &mut Session, p: &Value) -> Result { Some(Look::custom(c, o)) } }; + if let Some(l) = &mut look { + attach_pbr(cmd, p, l)?; + } else if p.get("pbr").is_some_and(|v| !v.is_null()) { + return Err(bad(cmd, "`pbr` requires a color or appearance")); + } let bodies = string_list(p, "bodies"); let st = s.model.state(); if bodies.iter().any(|b| st.body(b).is_none()) { @@ -466,7 +471,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result { "bodies": bodies, "faces": faces.iter().map(|(b, p)| json!({"body": b, "point": p})).collect::>(), "components": comps, - "appearance": look.as_ref().map(|l| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})), + "appearance": look.as_ref().map(look_json), })) } @@ -477,7 +482,40 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 { } pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value { - json!({"name": l.name, "color": l.hex(), "opacity": l.opacity}) + let mut value = json!({"name": l.name, "color": l.hex(), "opacity": l.opacity}); + if let Some(pbr) = l.pbr { + value["pbr"] = json!(pbr); + } + value +} + +#[cfg(test)] +#[path = "pbr_appearance_tests.rs"] +mod pbr_tests; + +pub(super) fn attach_pbr(cmd: &str, params: &Value, look: &mut solidcraft_doc::appearance::Look) -> Result<()> { + let Some(value) = params.get("pbr") else { return Ok(()) }; + if value.is_null() { + look.pbr = None; + return Ok(()); + } + if let Some(opacity) = params.get("opacity") + && opacity.as_f64().filter(|v| v.is_finite() && *v == 1.0).is_none() + { + return Err(bad(cmd, "opaque PBR requires numeric opacity 1")); + } + let object = value + .as_object() + .filter(|o| o.len() == 3 && o.keys().all(|k| matches!(k.as_str(), "metallic" | "roughness" | "dielectric_ior"))) + .ok_or_else(|| bad(cmd, "`pbr` requires exactly metallic, roughness and dielectric_ior"))?; + let number = |key: &str| object.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("`pbr.{key}` must be a finite number"))); + let response = solidcraft_doc::pbr_appearance::PbrParameters::new(number("metallic")?, number("roughness")?, number("dielectric_ior")?) + .map_err(|e| bad(cmd, e.to_string()))?; + if look.opacity != 1.0 { + return Err(bad(cmd, "opaque PBR requires opacity 1; refraction/transmission is not supported")); + } + look.pbr = Some(response); + Ok(()) } fn appearance_library(_s: &mut Session, _p: &Value) -> Result { @@ -524,7 +562,13 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result { } let in_design: Vec = used .iter() - .map(|(l, b, f, c)| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity, "bodies": b, "faces": f, "components": c})) + .map(|(l, b, f, c)| { + let mut value = look_json(l); + value["bodies"] = json!(b); + value["faces"] = json!(f); + value["components"] = json!(c); + value + }) .collect(); let mut out = json!({ "in_design": in_design, diff --git a/crates/engine/src/cmd/libraries_tests.rs b/crates/engine/src/cmd/libraries_tests.rs index b84fb6b3..0cb53ce9 100644 --- a/crates/engine/src/cmd/libraries_tests.rs +++ b/crates/engine/src/cmd/libraries_tests.rs @@ -218,3 +218,35 @@ fn catalog_inspection_export_and_no_clobber_do_not_touch_design_or_history() { assert!(s.execute(command, &json!({"path":file.0,"unknown":true})).is_err()); } } + +#[test] +fn lossy_scalar_export_never_creates_or_replaces_a_file_or_changes_history() { + let mut s = session(); + s.execute( + "appearance.assign", + &json!({"bodies":["Body1"],"color":[180,120,70], + "pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}), + ) + .unwrap(); + s.mark_saved(); + let doc = s.doc.clone(); + let history = (s.undo.len(), s.redo.len(), s.revision); + for existing in [false, true] { + let file = File::new(); + if existing { + std::fs::write(&file.0, b"preserve existing library").unwrap(); + } + for overwrite in [false, true] { + let error = s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite})).unwrap_err(); + assert!(error.to_string().contains("cannot preserve PBR")); + if existing { + assert_eq!(std::fs::read(&file.0).unwrap(), b"preserve existing library"); + } else { + assert!(!std::path::Path::new(&file.0).exists()); + } + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!((s.undo.len(), s.redo.len(), s.revision), history); + assert!(!s.is_dirty()); + } + } +} diff --git a/crates/engine/src/cmd/materials.rs b/crates/engine/src/cmd/materials.rs index df3fdb72..c96376a2 100644 --- a/crates/engine/src/cmd/materials.rs +++ b/crates/engine/src/cmd/materials.rs @@ -16,10 +16,10 @@ use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("material.create", "New Physical Material", create).icon("material").params( - "name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1): the look bodies of this material get without an appearance of their own. Assign it with material.assign", + "name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1), pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (opaque only, needs color/appearance): the default look. Optical edits do not change density. Assign it with material.assign", ), CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params( - "material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity? (as material.create; color: null clears the look)", + "material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). Omitted appearance/color retains the entire current look", ), CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params( "material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)", @@ -60,6 +60,9 @@ fn density(cmd: &str, p: &Value) -> Result { /// The look `appearance` / `color` / `opacity` give: `None` when none is given, `Some(None)` /// to clear it (`color: null`). fn look(cmd: &str, p: &Value, material: &str) -> Result>> { + if p.get("pbr").is_some() && str_(p, "appearance").is_none() && p.get("color").is_none_or(Value::is_null) { + return Err(bad(cmd, "`pbr` requires an appearance or color")); + } let opacity = match p.get("opacity") { Some(o) => Some(o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?), None => None, @@ -79,6 +82,7 @@ fn look(cmd: &str, p: &Value, material: &str) -> Result>> { if let Some(o) = opacity { l.opacity = o; } + super::inspect::attach_pbr(cmd, p, &mut l)?; Ok(Some(Some(l))) } diff --git a/crates/engine/src/cmd/pbr_appearance_tests.rs b/crates/engine/src/cmd/pbr_appearance_tests.rs new file mode 100644 index 00000000..9fdcccf9 --- /dev/null +++ b/crates/engine/src/cmd/pbr_appearance_tests.rs @@ -0,0 +1,146 @@ +use serde_json::json; +use std::sync::Arc; + +use crate::{Session, product_scene}; + +fn fixture() -> Session { + let mut s = Session::default(); + s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":20,"height":5})).unwrap(); + s +} + +#[test] +fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() { + let mut s = fixture(); + s.execute("appearance.assign", &json!({"bodies":["Specimen"],"appearance":"Original legacy look","color":"#b47846","opacity":0.4})).unwrap(); + let before = serde_json::to_value(&s.doc.appearances).unwrap(); + let mut legacy = serde_json::to_value(&*s.doc).unwrap(); + legacy["format"] = json!("solidcraft/20"); + legacy.as_object_mut().unwrap().remove("render"); + let upgraded = crate::doc::Document::from_json(&legacy.to_string()).unwrap(); + assert_eq!(upgraded.format, "solidcraft/21"); + assert_eq!(serde_json::to_value(&upgraded.appearances).unwrap(), before); + assert!(upgraded.render.setups.is_empty()); + s.execute("render.settings.set", &json!({"name":"Studio","set":{"width":320,"height":240}})).unwrap(); + let mut saved: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + saved["format"] = json!("solidcraft/21"); + let d = crate::doc::Document::from_json(&saved.to_string()).unwrap(); + assert_eq!(d.format, crate::doc::format::format_string(crate::doc::format::FORMAT)); + assert_eq!(serde_json::to_value(&d.appearances).unwrap(), before); + assert!(d.appearances.bodies["Specimen"].pbr.is_none()); + assert_eq!(d.render.setups["Studio"].width, 320); + for version in [22, 23] { + saved["format"] = json!(format!("solidcraft/{version}")); + assert!(crate::doc::Document::from_json(&saved.to_string()).is_err()); + } +} + +#[test] +fn opaque_response_preserves_geometry_mass_undo_and_cold_reopening() { + let mut s = fixture(); + let before = product_scene::extract(&s).unwrap(); + let mass = s.execute("component.properties", &json!({})).unwrap(); + s.execute( + "appearance.assign", + &json!({"bodies":["Specimen"],"color":"#b47846","appearance":"Original opaque test", + "pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}), + ) + .unwrap(); + let after = product_scene::extract(&s).unwrap(); + assert!(Arc::ptr_eq(&before.meshes[0].mesh, &after.meshes[0].mesh)); + assert_eq!(s.execute("component.properties", &json!({})).unwrap(), mass); + assert!(after.meshes[0].surface.is_some()); + assert_eq!(after.required_features(), vec![crate::render::RenderFeature::Pbr]); + let saved = s.doc.to_json(); + assert_eq!(serde_json::from_str::(&saved).unwrap()["format"], "solidcraft/21"); + let look = &s.doc.appearances.bodies["Specimen"]; + assert_eq!(crate::doc::config::appearance_cell(&serde_json::to_string(look).unwrap()).unwrap().as_ref(), Some(look)); + let list = s.execute("appearance.list", &json!({})).unwrap(); + assert_eq!(list["in_design"][0]["pbr"]["metallic"], 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(product_scene::extract(&s).unwrap().meshes[0].surface.is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(product_scene::extract(&s).unwrap().meshes[0].surface.is_some()); + let mut reopened = Session::new(crate::doc::Document::from_json(&saved).unwrap()); + assert_eq!(product_scene::extract(&reopened).unwrap().meshes[0].surface, after.meshes[0].surface); + assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), mass); +} + +#[test] +fn invalid_optical_requests_refuse_atomically_and_scalar_export_cannot_lose_them() { + let mut s = fixture(); + let doc = s.doc.clone(); + let history = (s.undo.len(), s.redo.len(), s.revision); + for params in [ + json!({"bodies":["Specimen"],"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}), + json!({"bodies":["Specimen"],"color":"#ffffff","pbr":{"metallic":0,"roughness":0,"dielectric_ior":1.5}}), + json!({"bodies":["Specimen"],"color":"#ffffff","opacity":0.5,"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}), + json!({"bodies":["Specimen"],"appearance":"Named","color":"#ffffff","opacity":"invalid","pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}), + json!({"bodies":["Specimen"],"appearance":"Named","color":"#ffffff","opacity":2,"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}), + json!({"bodies":["Specimen"],"color":"#ffffff","pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5,"transmission":1}}), + ] { + assert!(s.execute("appearance.assign", ¶ms).is_err()); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!((s.undo.len(), s.redo.len(), s.revision), history); + } + s.execute( + "appearance.assign", + &json!({"bodies":["Specimen"],"color":"#ffffff", + "pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}), + ) + .unwrap(); + assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err()); + let mut invalid: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + invalid["appearances"]["bodies"]["Specimen"]["opacity"] = json!(0.5); + assert!(crate::doc::Document::from_json(&invalid.to_string()).is_err()); + invalid["appearances"]["bodies"]["Specimen"]["opacity"] = json!(1.0); + invalid["appearances"]["bodies"]["Specimen"]["pbr"]["roughness"] = json!(0.0); + assert!(crate::doc::Document::from_json(&invalid.to_string()).is_err()); +} + +#[test] +fn physical_default_look_and_face_override_keep_independent_optical_meaning() { + let mut s = fixture(); + s.execute( + "material.create", + &json!({"name":"Original density fixture","density_g_cm3":0.7,"color":"#b47846", + "pbr":{"metallic":0,"roughness":0.4,"dielectric_ior":1.5}}), + ) + .unwrap(); + s.execute("material.assign", &json!({"bodies":["Specimen"],"material":"Original density fixture"})).unwrap(); + let scene = product_scene::extract(&s).unwrap(); + assert!(scene.meshes[0].surface.is_some()); + s.execute("appearance.assign", &json!({"faces":[[5,10,5]],"body":"Specimen","color":"#0080a0"})).unwrap(); + let scene = product_scene::extract(&s).unwrap(); + assert!(scene.meshes[0].surface.is_some()); + assert!(scene.meshes[0].faces.iter().any(|f| f.color == [0, 128, 160] && f.surface.is_none())); + assert_eq!(s.doc.user_materials[0].density, 0.7); + assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err()); +} + +#[test] +fn independent_component_copy_keeps_its_opaque_response_after_source_edits_and_reopen() { + let mut s = Session::default(); + let part = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap(); + s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":20,"height":5})).unwrap(); + s.execute( + "appearance.assign", + &json!({"components":["Part"],"color":"#b47846", + "pbr":{"metallic":1,"roughness":0.3,"dielectric_ior":1.5}}), + ) + .unwrap(); + s.execute("component.activate", &json!({"component":0})).unwrap(); + let copied = s.execute("component.paste_new", &json!({"component":part,"translate":[30,0,0]})).unwrap()["component"].as_u64().unwrap(); + let original = s.doc.appearances.components[&part].clone(); + assert_eq!(s.doc.appearances.components[&copied], original); + s.execute( + "appearance.assign", + &json!({"components":["Part"],"color":"#b47846", + "pbr":{"metallic":1,"roughness":0.8,"dielectric_ior":1.5}}), + ) + .unwrap(); + assert_ne!(s.doc.appearances.components[&part], original); + assert_eq!(s.doc.appearances.components[&copied], original); + let reopened = crate::doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(reopened.appearances.components[&copied], original); +} diff --git a/crates/engine/src/cmd/render.rs b/crates/engine/src/cmd/render.rs index 8a51111c..301cd5dd 100644 --- a/crates/engine/src/cmd/render.rs +++ b/crates/engine/src/cmd/render.rs @@ -10,7 +10,19 @@ use super::CommandSpec; use crate::params::bad; use crate::{Result, Session}; +mod jobs; + pub static COMMANDS: &[CommandSpec] = &[ +CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).noundo().doc_only() + .params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"), +CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("box") + .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).noundo().doc_only() + .params("job: {session, serial, generation}. Non-blocking progress, error and completed frame metadata; no image bytes and no rendering in this call."), +CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).noundo().doc_only() + .params("job: {session, serial, generation}. Requests cooperative cancellation; completed jobs remain completed. Late cancelled frames cannot be published."), +CommandSpec::new("render.export", "Export Render Image", jobs::export).noundo().doc_only() + .params("job: {session, serial, generation}, path: PNG destination, overwrite?: false. 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) .noundo() .doc_only() @@ -19,8 +31,37 @@ CommandSpec::new("render.settings.get", "Render Settings", get_settings).noundo( .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).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). Creates from defaults, 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("box") + .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).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 scene(s: &mut Session, params: &Value) -> Result { + 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 bodies: Vec<_> = scene + .meshes + .iter() + .map(|m| json!({"name":m.name,"triangles":m.mesh.triangles.len(),"color":m.color,"opacity":m.opacity,"face_appearances":m.faces.len()})) + .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 { + 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 { @@ -71,11 +112,11 @@ fn set_settings(s: &mut Session, params: &Value) -> Result { Ok(reply) } -fn backends(_: &mut Session, params: &Value) -> Result { +fn backends(s: &mut Session, params: &Value) -> Result { 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": [crate::render::builtin_backend()]})) + Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [s.render_backend()]})) } #[cfg(test)] @@ -152,3 +193,6 @@ mod tests { #[cfg(test)] mod settings_tests; + +#[cfg(test)] +mod job_tests; diff --git a/crates/engine/src/cmd/render/job_tests.rs b/crates/engine/src/cmd/render/job_tests.rs new file mode 100644 index 00000000..3caec0b4 --- /dev/null +++ b/crates/engine/src/cmd/render/job_tests.rs @@ -0,0 +1,219 @@ +use super::*; +use solidcraft_render::job::{CpuHost, JobId}; + +fn session() -> (Session, CpuHost) { + let host = CpuHost::cooperative(); + let mut s = Session::default(); + s.set_render_job_host(Some(host.clone())).unwrap(); + (s, host) +} + +fn request() -> Value { + json!({"settings":{"width":16,"height":16}}) +} +fn start(s: &mut Session) -> JobId { + serde_json::from_value(s.execute("render.start", &request()).unwrap()["job"].clone()).unwrap() +} +fn finish(host: &CpuHost) { + for _ in 0..1000 { + if !host.pump(4096).unwrap() { + return; + } + } + panic!("cooperative job did not finish"); +} + +#[test] +fn runtime_commands_preserve_document_history_and_evaluation() { + let (mut s, host) = session(); + let doc = s.doc.clone(); + let before = (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len()); + s.execute("render.preflight", &request()).unwrap(); + assert_eq!(host.admitted_bytes(), 0); + let id = start(&mut s); + assert_eq!(s.execute("render.status", &json!({"job":id})).unwrap()["progress"]["phase"], "queued"); + finish(&host); + let status = s.execute("render.status", &json!({"job":id})).unwrap(); + assert_eq!(status["progress"]["phase"], "completed"); + assert_eq!(status["frame"]["job"], json!(id)); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(before, (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len())); + assert_eq!(s.execute("render.cancel", &json!({"job":id})).unwrap()["progress"]["phase"], "completed"); +} + +#[test] +fn identities_are_isolated_across_documents_scratch_and_new() { + let (mut a, host) = session(); + let mut b = Session::default(); + b.set_render_job_host(Some(host.clone())).unwrap(); + let a_id = start(&mut a); + let b_id = start(&mut b); + assert_eq!(a_id.serial, b_id.serial); + assert_ne!(a_id.session, b_id.session); + assert!(b.execute("render.cancel", &json!({"job":a_id})).is_err()); + assert!(a.scratch().execute("render.status", &json!({"job":a_id})).is_err()); + a.execute("file.new", &json!({})).unwrap(); + assert!(a.render_job_host().unwrap().same_host(&host)); + assert!(a.execute("render.status", &json!({"job":a_id})).is_err()); + finish(&host); + assert_eq!(b.execute("render.status", &json!({"job":b_id})).unwrap()["progress"]["phase"], "completed"); +} + +#[test] +fn queue_cancellation_and_replacement_keep_the_last_successful_image() { + let (mut s, host) = session(); + let good = start(&mut s); + finish(&host); + s.execute("render.status", &json!({"job":good})).unwrap(); + let a = start(&mut s); + host.pump(1).unwrap(); + start(&mut s); + assert!(s.execute("render.start", &request()).is_err()); + s.execute("render.cancel", &json!({"job":a})).unwrap(); + let replacement = s.execute("render.start", &json!({"settings":{"width":16,"height":16},"replace":true})).unwrap(); + s.execute("render.cancel", &json!({"job":replacement["job"]})).unwrap(); + finish(&host); + assert!(s.render_jobs.frame(good).is_ok()); + assert_eq!(s.execute("render.status", &json!({"job":a})).unwrap()["progress"]["phase"], "cancelled"); +} + +#[test] +fn unsupported_intent_is_rejected_and_export_preserves_existing_files() { + let (mut s, host) = session(); + for params in [ + json!({"settings":{"mode":"final"}}), + json!({"settings":{"format":"exr"}}), + json!({"settings":{"backend":"missing"}}), + json!({"settings":{"exposure_ev":1}}), + json!({"unknown":true}), + ] { + assert!(s.execute("render.preflight", ¶ms).is_err()); + assert_eq!(host.admitted_bytes(), 0); + } + let id = start(&mut s); + let path = std::env::temp_dir().join(format!("solidcraft-job-{}-{}.png", std::process::id(), id.session)); + assert!(s.execute("render.export", &json!({"job":id,"path":path})).is_err()); + finish(&host); + let missing = path.with_file_name("solidcraft-render-missing-directory").join("image.png"); + assert!(s.execute("render.export", &json!({"job":id,"path":missing})).is_err()); + assert!(s.render_jobs.frame(id).is_ok()); + s.execute("render.export", &json!({"job":id,"path":path})).unwrap(); + let png = std::fs::read(&path).unwrap(); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + assert!(s.execute("render.export", &json!({"job":id,"path":path})).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), png); + s.execute("render.export", &json!({"job":id,"path":path,"overwrite":true})).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), png); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn open_close_and_host_replacement_cancel_work_and_reject_old_ids() { + let (mut s, host) = session(); + let path = std::env::temp_dir().join(format!("solidcraft-job-open-{}.solidcraft", std::process::id())); + s.execute("file.save", &json!({"path":path})).unwrap(); + let id = start(&mut s); + let handle = s.render_job_handle(id).unwrap(); + s.execute("doc.open", &json!({"path":path})).unwrap(); + assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled); + assert!(s.execute("render.status", &json!({"job":id})).is_err()); + assert!(s.render_job_host().unwrap().same_host(&host)); + let id = start(&mut s); + let handle = s.render_job_handle(id).unwrap(); + s.replace_design(Session::default()); + assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled); + assert!(s.execute("render.status", &json!({"job":id})).is_err()); + assert!(s.render_job_host().unwrap().same_host(&host)); + let id = start(&mut s); + let handle = s.render_job_handle(id).unwrap(); + let replacement = CpuHost::cooperative(); + s.set_render_job_host(Some(replacement.clone())).unwrap(); + assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled); + assert!(s.execute("render.status", &json!({"job":id})).is_err()); + let id = start(&mut s); + let handle = s.render_job_handle(id).unwrap(); + drop(s); + assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled); + finish(&host); + finish(&replacement); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn captured_image_is_immutable_across_edit_and_undo_and_stale_preflight_fails() { + let (mut s, host) = session(); + s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); + let revision = s.revision; + let id = start(&mut s); + s.execute("solid.box", &json!({"length":3,"width":4,"height":5})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + finish(&host); + let status = s.execute("render.status", &json!({"job":id})).unwrap(); + assert_eq!(status["scene_generation"], revision); + assert_eq!(status["progress"]["phase"], "completed"); + assert_ne!(s.revision, revision); + // Reuse the real live-gesture regression rather than manufacturing model state. + for (command, params) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})), + ("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(command, ¶ms).unwrap(); + } + assert!(s.model.is_stale()); + let evaluations = s.evaluations; + assert!(s.execute("render.preflight", &request()).is_err()); + assert!(s.execute("render.start", &request()).is_err()); + assert!(s.model.is_stale()); + assert_eq!(s.evaluations, evaluations); +} + +#[test] +fn native_start_and_cancel_measure_cached_capture_without_timing_assertions() { + use std::time::{Duration, Instant}; + let mut s = Session::default(); + s.set_render_job_host(Some(CpuHost::for_platform().unwrap())).unwrap(); + s.execute("solid.box", &json!({"length":40,"width":30,"height":20})).unwrap(); + s.execute("render.scene", &json!({})).unwrap(); + let before = Instant::now(); + let reply = s.execute("render.start", &json!({})).unwrap(); + let start_time = before.elapsed(); + let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap(); + let before = Instant::now(); + s.execute("render.cancel", &json!({"job":id})).unwrap(); + let cancel_request_time = before.elapsed(); + let status = s.render_job_handle(id).unwrap().wait_timeout(Duration::from_secs(60)).unwrap(); + let cancellation_time = before.elapsed(); + assert!(matches!(status.progress.phase, solidcraft_render::job::Phase::Cancelled | solidcraft_render::job::Phase::Completed)); + if status.progress.phase == solidcraft_render::job::Phase::Cancelled { + assert!(status.frame.is_none()); + } + eprintln!( + "cached one-box 1024x768: start={start_time:?}; cancel request={cancel_request_time:?}; cancellation acknowledged={cancellation_time:?}" + ); + let reply = s.execute("render.start", &json!({})).unwrap(); + let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap(); + let handle = s.render_job_handle(id).unwrap(); + let watch = Instant::now(); + let observed = loop { + let phase = handle.status().unwrap().progress.phase; + if phase == solidcraft_render::job::Phase::Running || phase.terminal() { + break phase; + } + assert!(watch.elapsed() < Duration::from_secs(60), "native worker did not advance"); + std::thread::yield_now(); + }; + let before = Instant::now(); + s.execute("render.cancel", &json!({"job":id})).unwrap(); + let request_time = before.elapsed(); + let status = handle.wait_timeout(Duration::from_secs(60)).unwrap(); + eprintln!( + "native cancellation observed phase={observed:?}: request={request_time:?}; acknowledged={:?}; terminal={:?}", + before.elapsed(), + status.progress.phase + ); + assert!(status.progress.phase.terminal()); +} diff --git a/crates/engine/src/cmd/render/jobs.rs b/crates/engine/src/cmd/render/jobs.rs new file mode 100644 index 00000000..8056c627 --- /dev/null +++ b/crates/engine/src/cmd/render/jobs.rs @@ -0,0 +1,133 @@ +use crate::{Result, Session, params::bad, render_jobs::job_error}; +use serde::Deserialize; +use serde_json::{Value, json}; +use solidcraft_doc::render_settings::RenderSetup; +use solidcraft_render::{ + RenderOperation, RenderRequest, + job::{ImageRequest, JobId}, +}; + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct Start { + setup: Option, + settings: Option, + transparent_background: bool, + replace: bool, +} + +fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcraft_render::ProductScene, ImageRequest, Value)> { + let input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?; + if input.setup.is_some() && input.settings.is_some() { + return Err(bad(command, "choose either setup or settings")); + } + let scene = crate::product_scene::extract(s)?; + let settings = if let Some(name) = &input.setup { + s.doc.render.setups.get(name).cloned().ok_or_else(|| bad(command, "no saved render setup with that name"))? + } else if let Some(settings) = &input.settings { + settings.clone() + } else { + let mut settings = RenderSetup::default(); + if let Some(camera) = s.camera { + settings.camera = camera; + } else { + settings.camera.fit(&scene.bounds); + } + settings + }; + settings.validate().map_err(|e| bad(command, e.to_string()))?; + if settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 { + return Err(bad(command, "exposure and environment controls are not supported by the R4a CPU image executor")); + } + let host = s.render_job_host().ok_or_else(|| bad(command, "no image host is installed"))?; + if !host.is_available() { + return Err(bad(command, "image host is unavailable")); + } + let resolved = solidcraft_render::resolve_request( + &[s.render_backend()], + &RenderRequest { + backend: Some(settings.backend.clone()), + mode: settings.mode, + operation: RenderOperation::Image, + width: u64::from(settings.width), + height: u64::from(settings.height), + format: Some(settings.format), + features: scene.required_features(), + policy: Default::default(), + }, + ) + .map_err(|e| bad(command, e.to_string()))?; + if resolved.executor.as_str() != "cpu-product" { + return Err(bad(command, "resolved executor is not installed as an image job host")); + } + let request = ImageRequest { + camera: settings.camera, + width: settings.width, + height: settings.height, + background: settings.background, + transparent_background: input.transparent_background, + }; + let admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?; + let reply = json!({"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":"sorted_alpha_approximation","refraction":false}); + Ok((input, scene, request, reply)) +} + +pub(super) fn preflight(s: &mut Session, params: &Value) -> Result { + prepare(s, params, "render.preflight").map(|(_, _, _, reply)| reply) +} + +pub(super) fn start(s: &mut Session, params: &Value) -> Result { + let (input, scene, request, _) = prepare(s, params, "render.start")?; + let status = s.render_jobs.start(s.revision, scene, request, input.replace).map_err(job_error)?; + Ok(json!(status)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Job { + job: JobId, +} + +pub(super) fn status(s: &mut Session, params: &Value) -> Result { + let input: Job = serde_json::from_value(params.clone()).map_err(|e| bad("render.status", e.to_string()))?; + Ok(json!(s.render_jobs.status(input.job).map_err(job_error)?)) +} + +pub(super) fn cancel(s: &mut Session, params: &Value) -> Result { + let input: Job = serde_json::from_value(params.clone()).map_err(|e| bad("render.cancel", e.to_string()))?; + let status = s.render_jobs.status(input.job).map_err(job_error)?; + if status.progress.phase.terminal() { + return Ok(json!(status)); + } + Ok(json!(s.render_jobs.handle(input.job).map_err(job_error)?.cancel().map_err(job_error)?)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Export { + job: JobId, + path: String, + #[serde(default)] + overwrite: bool, +} + +pub(super) fn export(s: &mut Session, params: &Value) -> Result { + let input: Export = serde_json::from_value(params.clone()).map_err(|e| bad("render.export", e.to_string()))?; + let path = std::path::Path::new(&input.path); + if input.path.len() > 4096 || input.path.chars().any(char::is_control) || !path.extension().is_some_and(|x| x.eq_ignore_ascii_case("png")) { + return Err(bad("render.export", "a bounded PNG destination path is required")); + } + // Atomic native writers are also used for the app's own files and do not + // apply a request's path scope. Check the caller's destination explicitly. + solidcraft_io::vfs::check(&input.path).map_err(|e| bad("render.export", e.to_string()))?; + let frame = s.render_jobs.frame(input.job).map_err(job_error)?; + // Replace atomically without reading an arbitrarily large previous target into memory. + // Design saves retain their independent backup policy. + let result = if input.overwrite { + solidcraft_io::write_atomic(path, frame.png(), false) + } else { + solidcraft_io::safe_write::create_atomic(path, frame.png()) + }; + result.map_err(|e| bad("render.export", format!("could not export PNG: {e}")))?; + Ok(json!({"job":input.job,"path":input.path,"bytes":frame.png().len(),"frame":frame.info})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 04fb0e2b..ae7f8741 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,7 +15,9 @@ pub mod fuzz; pub mod licences; pub mod occurrences; pub mod params; +pub mod product_scene; pub mod recovery; +pub mod render_jobs; pub mod sample; pub mod view; @@ -228,6 +230,9 @@ pub struct Session { /// moves its viewport there and takes it; headless renders keep using it until a standard /// view is chosen. pub camera: Option, + /// Host-owned viewport metadata, never saved or included in history. + render_viewport: Option>, + render_jobs: render_jobs::Jobs, /// The visibility the design file holds (what `is_dirty` compares with). saved_visibility: Visibility, /// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of @@ -324,6 +329,8 @@ impl Session { saved_visibility: visibility.clone(), visibility, camera: None, + render_viewport: None, + render_jobs: Default::default(), #[cfg(test)] evaluations: 0, crash_journal: false, @@ -334,8 +341,12 @@ impl Session { /// are the user's rather than the design's: `auto_project` is a preference (#680). pub fn replace_design(&mut self, fresh: Session) { let auto_project = self.auto_project; + let render_viewport = self.render_viewport.clone(); + let render_jobs = self.render_jobs.scratch(); *self = fresh; self.auto_project = auto_project; + self.render_viewport = render_viewport; + self.render_jobs = render_jobs; } /// The design has changes not in its file: in the document, or in what is hidden. @@ -347,6 +358,56 @@ impl Session { self.saved_visibility = self.visibility.clone(); } + pub fn render_backend(&self) -> render::BackendDescriptor { + let mut backend = render::builtin_backend(); + if let Some(viewport) = &self.render_viewport { + backend.executors.push((**viewport).clone()); + } + if let Some(executor) = &self.render_jobs.descriptor { + let mut executor = executor.clone(); + if self.render_jobs.host.as_ref().is_none_or(|host| !host.is_available()) { + executor.availability = render::Availability::Unavailable { reason: "CPU image host is unavailable".into() }; + } + backend.executors.push(executor); + } + backend + } + + pub fn render_viewport(&self) -> Option<&render::ExecutorDescriptor> { + self.render_viewport.as_deref() + } + + /// Installed hosts supply pure metadata; document commands cannot invent a device. + pub fn set_render_viewport(&mut self, viewport: Option) -> Result<()> { + if self.render_viewport.as_deref() == viewport.as_ref() { + return Ok(()); + } + if let Some(executor) = &viewport { + if executor.profiles.is_empty() || executor.profiles.iter().any(|p| p.operation != render::RenderOperation::Viewport) { + return Err(EngineError::Other("host viewport executor must contain only viewport profiles".into())); + } + let mut backend = render::builtin_backend(); + if let Some(executor) = &self.render_jobs.descriptor { + backend.executors.push(executor.clone()); + } + backend.executors.push(executor.clone()); + // Resolution validates the whole registry, including duplicate executor IDs. + let request = render::RenderRequest { + backend: None, + mode: render::RenderMode::Preview, + operation: render::RenderOperation::Image, + width: 1, + height: 1, + format: Some(render::RenderFormat::Png), + features: Vec::new(), + policy: Default::default(), + }; + render::resolve_request(&[backend], &request).map_err(|e| EngineError::Other(e.to_string()))?; + } + self.render_viewport = viewport.map(Arc::new); + Ok(()) + } + /// The design as a file holds it: the document with what is hidden in it. pub fn design(&self) -> std::borrow::Cow<'_, Document> { if self.visibility.is_empty() { @@ -549,6 +610,8 @@ impl Session { /// the model up to date before anything reads it. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + let host_viewport = self.render_viewport.clone(); + let host_jobs = self.render_jobs.host.clone(); let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true)); if live.is_none() && spec.reads_model && self.model.is_stale() { self.refresh(); @@ -584,6 +647,12 @@ impl Session { Err(EngineError::Internal(spec.id.to_string(), msg)) } }; + // New/open/recover can replace the Session. Device ownership belongs to the host, + // so its capability metadata survives those document lifecycle commands. + self.render_viewport = host_viewport; + if self.render_jobs.host.is_none() && host_jobs.is_some() { + self.set_render_job_host(host_jobs)?; + } if outermost && crash::enabled() { // A failed command leaves the design as it was (restored below). let failed = r.as_ref().err().map(ToString::to_string); @@ -724,6 +793,8 @@ impl Session { next_checkpoint: 1, visibility: self.visibility.clone(), camera: self.camera, + render_viewport: self.render_viewport.clone(), + render_jobs: self.render_jobs.scratch(), saved_visibility: self.saved_visibility.clone(), #[cfg(test)] evaluations: 0, diff --git a/crates/engine/src/product_scene.rs b/crates/engine/src/product_scene.rs new file mode 100644 index 00000000..e389d50f --- /dev/null +++ b/crates/engine/src/product_scene.rs @@ -0,0 +1,198 @@ +//! Product extraction shares evaluated placement and appearance resolution, not viewport policy. + +use crate::geom::Aabb3; +use crate::render::{FaceAppearance, ProductMesh, ProductScene}; +use crate::{EngineError, Result, Session}; + +pub const MAX_PRODUCT_BODIES: usize = 16_384; + +/// Extract authoritative, visible geometry. Does not settle a live gesture implicitly. +/// `render.scene` is the explicit model-reading command for that boundary. +pub fn extract(s: &Session) -> Result { + if s.model.is_stale() { + return Err(EngineError::Other("render scene is stale; finish the modelling gesture before previewing".into())); + } + let world = s.world_state(); + let mut scene = ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY }; + for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) { + if scene.meshes.len() >= MAX_PRODUCT_BODIES { + return Err(EngineError::Other("render scene exceeds 16384 visible bodies".into())); + } + let mesh = body.mesh(); + scene.bounds = scene.bounds.union(&mesh.bounds()); + let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let color = s.doc.body_color(body).map(|c| c.map(to8)).unwrap_or([176, 186, 198]); + let faces = s + .doc + .face_colors(body) + .into_iter() + .map(|(face, look)| { + Ok(FaceAppearance { + face: u32::try_from(face).map_err(|_| EngineError::Other("render face index exceeds the mesh contract".into()))?, + color: look.color, + opacity: look.opacity.clamp(0.0, 1.0), + surface: look.opaque_surface().map_err(|e| EngineError::Other(e.to_string()))?, + }) + }) + .collect::>>()?; + let surface = s.doc.model_body_look(body).map(|l| l.opaque_surface()).transpose().map_err(|e| EngineError::Other(e.to_string()))?.flatten(); + scene.meshes.push(ProductMesh { + name: body.name.clone(), + mesh, + color, + opacity: s.doc.model_body_opacity(body).clamp(0.0, 1.0), + surface, + faces, + }); + } + Ok(scene) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Sel; + use crate::geom::Vec3; + use crate::render::*; + use serde_json::json; + use std::sync::Arc; + + #[test] + fn product_scene_keeps_placed_appearances_and_meshes_without_design_overlays() { + let mut s = Session::default(); + let component = s.execute("component.create", &json!({"name":"Part"})).unwrap(); + s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap(); + s.execute("appearance.assign", &json!({"bodies":["Block"],"color":"#20a080","opacity":0.4})).unwrap(); + s.execute("appearance.assign", &json!({"faces":[[5,5,10]],"color":"#ff0000","opacity":0.7})).unwrap(); + s.execute("component.activate", &json!({"component":0})).unwrap(); + s.execute("occurrence.copy", &json!({"component":component["component"],"translate":[30,0,0]})).unwrap(); + s.execute("occurrence.move", &json!({"occurrence":component["occurrence"],"translate":[10,0,0]})).unwrap(); + let doc = s.doc.clone(); + s.evaluations = 0; + let first = extract(&s).unwrap(); + assert_eq!(first.meshes.len(), 2); + assert_eq!(first.bounds.min, Vec3::new(10.0, 0.0, 0.0)); + assert_eq!(first.bounds.max, Vec3::new(40.0, 10.0, 10.0)); + for m in &first.meshes { + assert_eq!(m.color, [32, 160, 128]); + assert_eq!(m.opacity, 0.4); + assert!(m.faces.iter().any(|f| f.color == [255, 0, 0] && f.opacity == 0.7)); + assert!(Arc::ptr_eq(&m.mesh, &s.world_state().body(&m.name).unwrap().mesh())); + } + s.selection = vec![Sel::Body { name: first.meshes[0].name.clone() }]; + s.section = Some((Vec3::ZERO, Vec3::X)); + s.analysis = Some(crate::SurfaceAnalysis::Zebra { stripes: 10.0 }); + let after = extract(&s).unwrap(); + for (a, b) in first.meshes.iter().zip(&after.meshes) { + assert!(Arc::ptr_eq(&a.mesh, &b.mesh)); + assert_eq!((a.color, a.opacity, &a.faces), (b.color, b.opacity, &b.faces)); + } + s.active_component = component["component"].as_u64().unwrap(); + let active = extract(&s).unwrap(); + assert_eq!(active.bounds.min, first.bounds.min); + assert_eq!(active.meshes[1].opacity, 0.4, "no active-component fading"); + s.visibility.hidden_bodies.push(first.meshes[1].name.clone()); + assert_eq!(extract(&s).unwrap().meshes.len(), 1); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(s.evaluations, 0); + } + + #[test] + fn stale_geometry_requires_an_explicit_model_read_and_camera_apply_preserves_staleness() { + 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()); + assert!(extract(&s).is_err()); + s.evaluations = 0; + let camera = Camera { up: UpAxis::Y, roll: 0.25, ..Default::default() }; + s.execute("render.settings.set", &json!({"name":"Photo","set":{"camera":camera}})).unwrap(); + let doc = s.doc.clone(); + let undo = s.undo.len(); + s.execute("render.settings.apply", &json!({"name":"Photo"})).unwrap(); + assert_eq!(s.camera, Some(camera)); + assert!(s.model.is_stale()); + assert_eq!(s.evaluations, 0); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(s.undo.len(), undo); + let metadata = s.execute("render.scene", &json!({})).unwrap(); + assert!(!s.model.is_stale()); + assert!(s.evaluations > 0); + assert_eq!(metadata["overlays"], false); + assert_eq!(metadata["bodies"].as_array().unwrap().len(), 1); + } + + #[test] + fn host_capabilities_are_validated_runtime_only_and_survive_new_design() { + let mut s = Session::default(); + let viewport = ExecutorDescriptor { + id: ExecutorId::new("gpu-viewport").unwrap(), + availability: Availability::Available, + profiles: vec![RenderProfile { + mode: RenderMode::Preview, + operation: RenderOperation::Viewport, + formats: Vec::new(), + features: Vec::new(), + }], + limits: RenderLimits::default(), + scratch: ScratchLayout { sample_scale: 1, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 0 }, + }; + let doc = s.doc.clone(); + s.set_render_viewport(Some(viewport.clone())).unwrap(); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(s.evaluations, 0); + assert!(!s.is_dirty()); + let backend = s.render_backend(); + s.replace_design(Session::default()); + assert_eq!(s.render_viewport(), Some(&viewport)); + assert_eq!(s.render_backend(), backend); + assert_eq!(backend.executors.len(), 2); + assert_eq!(s.scratch().render_backend(), backend); + let mut invalid = viewport; + invalid.id = ExecutorId::new("cpu-raster").unwrap(); + assert!(s.set_render_viewport(Some(invalid)).is_err()); + assert_eq!(s.render_backend(), backend); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.render_backend(), backend); + assert!(!s.doc.to_json().contains("gpu-viewport")); + s.set_render_viewport(None).unwrap(); + assert_eq!(s.render_backend(), builtin_backend()); + } + + #[test] + fn imported_looks_survive_product_extraction_and_design_overrides() { + let body = crate::kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)) + .unwrap() + .with_color(Some([0.2, 0.6, 0.8])) + .with_paint(Some(crate::kernel::Paint { opacity: 0.4, faces: Vec::new() })); + let step = crate::kernel::step_export_bodies( + &[crate::kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }], + &Default::default(), + ) + .unwrap(); + let mut document = crate::doc::Document::default(); + document + .add_feature(crate::doc::FeatureKind::Import { file: "original-generated.step".into(), step: step.into(), components: Vec::new() }, None) + .unwrap(); + let mut s = Session::new(document); + let first = extract(&s).unwrap(); + assert_eq!(first.meshes[0].color, [51, 153, 204]); + assert!((first.meshes[0].opacity - 0.4).abs() < 1e-6); + let name = first.meshes[0].name.clone(); + s.execute("appearance.assign", &json!({"bodies":[name],"color":"#336699","opacity":0.7})).unwrap(); + let changed = extract(&s).unwrap(); + assert_eq!(changed.meshes[0].color, [51, 102, 153]); + assert_eq!(changed.meshes[0].opacity, 0.7); + assert!(Arc::ptr_eq(&first.meshes[0].mesh, &changed.meshes[0].mesh)); + } +} diff --git a/crates/engine/src/render_jobs.rs b/crates/engine/src/render_jobs.rs new file mode 100644 index 00000000..267e171d --- /dev/null +++ b/crates/engine/src/render_jobs.rs @@ -0,0 +1,197 @@ +//! 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 solidcraft_render::{ + ExecutorDescriptor, + job::{CpuHost, ErrorCode, JobError, JobHandle, JobId, JobStatus, Phase, RenderFrame}, +}; +use std::collections::VecDeque; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); + +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 Jobs { + owner: u64, + serial: u64, + generation: u64, + pub(crate) host: Option, + pub(crate) descriptor: Option, + live: Vec, + history: VecDeque, + last_good: Option, +} + +impl Default for Jobs { + fn default() -> Self { + Self { owner: owner(), serial: 0, generation: 0, host: None, descriptor: None, live: Vec::new(), history: VecDeque::new(), last_good: None } + } +} + +impl Drop for Jobs { + fn drop(&mut self) { + for job in &self.live { + let _ = job.cancel(); + } + } +} + +impl Jobs { + pub(crate) fn scratch(&self) -> Self { + let mut jobs = Self::default(); + jobs.host = self.host.clone(); + jobs.descriptor = self.descriptor.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( + &mut self, + revision: u64, + scene: solidcraft_render::ProductScene, + request: solidcraft_render::job::ImageRequest, + replace: bool, + ) -> Result { + self.collect()?; + let host = self.host.as_ref().ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no image host is 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"))?; + let handle = host.submit(JobId { session: self.owner, serial, generation }, revision, scene, request)?; + 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 { + self.check_owner(id)?; + self.live + .iter() + .chain(self.last_good.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 { + 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 frame(&mut self, id: JobId) -> Result, JobError> { + self.collect()?; + self.handle(id)?.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "render has no completed image")) + } +} + +impl crate::Session { + /// A host is installed by the application, never by document data or a command. + pub fn set_render_job_host(&mut self, host: Option) -> 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)?; + let mut backend = self.render_backend(); + backend.executors.retain(|e| self.render_jobs.descriptor.as_ref().is_none_or(|old| old.id != e.id)); + if let Some(d) = &descriptor { + backend.executors.push(d.clone()); + } + 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.host = host; + jobs.descriptor = descriptor; + self.render_jobs = jobs; + 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 { + self.render_jobs.handle(id).map_err(job_error) + } +} + +pub(crate) fn job_error(error: JobError) -> crate::EngineError { + crate::EngineError::Other(error.to_string()) +} diff --git a/crates/io/src/catalog.rs b/crates/io/src/catalog.rs index 9e77fd09..2b764aef 100644 --- a/crates/io/src/catalog.rs +++ b/crates/io/src/catalog.rs @@ -57,11 +57,14 @@ pub struct ScalarLook { pub opacity: f64, } impl ScalarLook { - pub fn from_look(look: &Look) -> Self { - Self { name: look.name.clone(), color: look.color, opacity: look.opacity } + pub fn from_look(look: &Look) -> Result { + if look.pbr.is_some() { + return Err(invalid("scalar catalog v1 cannot preserve PBR parameters; export refused")); + } + Ok(Self { name: look.name.clone(), color: look.color, opacity: look.opacity }) } pub fn to_look(&self) -> Look { - Look { name: self.name.clone(), color: self.color, opacity: self.opacity } + Look { name: self.name.clone(), color: self.color, opacity: self.opacity, pbr: None } } fn validate(&self) -> Result<()> { if !text(&self.name, 128) || !self.opacity.is_finite() || !(0.0..=1.0).contains(&self.opacity) { @@ -80,8 +83,12 @@ pub struct ScalarMaterial { pub appearance: Option, } impl ScalarMaterial { - pub fn from_material(material: &UserMaterial) -> Self { - Self { name: material.name.clone(), density_g_cm3: material.density, appearance: material.appearance.as_ref().map(ScalarLook::from_look) } + pub fn from_material(material: &UserMaterial) -> Result { + Ok(Self { + name: material.name.clone(), + density_g_cm3: material.density, + appearance: material.appearance.as_ref().map(ScalarLook::from_look).transpose()?, + }) } pub fn to_material(&self, name: String) -> UserMaterial { UserMaterial { name, density: self.density_g_cm3, appearance: self.appearance.as_ref().map(ScalarLook::to_look) } @@ -266,12 +273,12 @@ impl Catalog { Ok(()) }; for look in doc.appearances.bodies.values().chain(doc.appearances.components.values()).chain(doc.appearances.faces.iter().map(|f| &f.look)) { - insert(Definition::Appearance(ScalarLook::from_look(look)))?; + insert(Definition::Appearance(ScalarLook::from_look(look)?))?; } for material in &doc.user_materials { - insert(Definition::Material(ScalarMaterial::from_material(material)))?; + insert(Definition::Material(ScalarMaterial::from_material(material)?))?; if let Some(look) = &material.appearance { - insert(Definition::Appearance(ScalarLook::from_look(look)))?; + insert(Definition::Appearance(ScalarLook::from_look(look)?))?; } } let out = Self { format: FORMAT.into(), id: id.into(), name: name.into(), entries: entries.into_values().collect() }; diff --git a/crates/io/src/catalog_tests.rs b/crates/io/src/catalog_tests.rs index 7af43c12..1b65e795 100644 --- a/crates/io/src/catalog_tests.rs +++ b/crates/io/src/catalog_tests.rs @@ -123,3 +123,34 @@ fn design_export_deduplicates_exact_looks_and_keeps_physical_properties_distinct assert_eq!(library, Catalog::decode(&library.encode().unwrap()).unwrap()); assert!(Catalog::from_document(&Document::default(), "user.test", "Test", Provenance { license: String::new(), ..Default::default() }).is_err()); } + +#[test] +fn scalar_export_refuses_pbr_in_every_design_owned_appearance_location() { + use solidcraft_doc::{appearance::FaceLook, pbr_appearance::PbrParameters}; + let mut look = ScalarLook { name: "Original PBR specimen".into(), color: [180, 120, 70], opacity: 1.0 }.to_look(); + look.pbr = Some(PbrParameters::new(1.0, 0.3, 1.5).unwrap()); + for location in 0..4 { + let mut doc = Document::default(); + match location { + 0 => { + doc.appearances.bodies.insert("Specimen".into(), look.clone()); + } + 1 => { + doc.appearances.components.insert(1, look.clone()); + } + 2 => doc.appearances.faces.push(FaceLook { + body: "Specimen".into(), + point: Default::default(), + look: look.clone(), + name: None, + strict_name: false, + certificate: None, + }), + _ => doc.user_materials.push(UserMaterial { name: "Original material".into(), density: 1.2, appearance: Some(look.clone()) }), + } + let before = doc.clone(); + let error = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap_err(); + assert!(error.to_string().contains("cannot preserve PBR")); + assert_eq!(doc, before); + } +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 33ce7b0d..a316d9fd 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -172,7 +172,15 @@ impl Default for Headless { } impl Headless { - pub fn new(session: Session) -> Self { + pub fn new(mut session: Session) -> Self { + match solidcraft_engine::render::job::CpuHost::for_platform() { + Ok(host) => { + if let Err(e) = session.set_render_job_host(Some(host)) { + session.log.push(format!("image host unavailable: {e}")); + } + } + Err(e) => session.log.push(format!("image host unavailable: {e}")), + } Headless { session, view: StandardView::Iso } } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 2f278cf9..999d90af 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -12,6 +12,7 @@ workspace = true solidcraft-geom = { workspace = true } serde = { workspace = true } image = { workspace = true, features = ["jpeg"] } +png = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/src/job/host.rs b/crates/render/src/job/host.rs new file mode 100644 index 00000000..12f09f6f --- /dev/null +++ b/crates/render/src/job/host.rs @@ -0,0 +1,516 @@ +//! Caller-owned CPU execution host. One worker, two bounded queued inputs; browser pumping +//! uses the same incremental task without threads. Shared ownership never includes CAD state. + +use std::collections::VecDeque; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Condvar; +use std::sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; + +use serde::Serialize; + +use super::{ + Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageRequest, ImageTask, JobError, JobId, Phase, Progress, Result, + TaskPoll, admit, +}; +use crate::{ + Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFormat, RenderLimits, RenderMode, RenderOperation, RenderProfile, ScratchLayout, +}; + +const MEMORY_LIMIT: u64 = 512 * 1024 * 1024; +const QUEUE_LIMIT: usize = 2; + +#[cfg(test)] +#[path = "host_tests.rs"] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HostKind { + NativeWorker, + Cooperative, +} + +struct Budget { + used: AtomicU64, +} +struct Reservation { + budget: Arc, + bytes: u64, +} + +impl Budget { + fn reserve(self: &Arc, bytes: u64) -> Result { + let mut used = self.used.load(Ordering::Acquire); + loop { + let next = used + .checked_add(bytes) + .filter(|n| *n <= MEMORY_LIMIT) + .ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU image host's 512 MiB admission pool is full"))?; + match self.used.compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire) { + Ok(_) => break, + Err(current) => used = current, + } + } + Ok(Reservation { budget: self.clone(), bytes }) + } +} +impl Reservation { + fn shrink(&mut self, bytes: u64) -> Result<()> { + if bytes > self.bytes { + return Err(JobError::new(ErrorCode::LimitExceeded, "completed PNG exceeds its reservation")); + } + self.budget.used.fetch_sub(self.bytes - bytes, Ordering::AcqRel); + self.bytes = bytes; + Ok(()) + } +} +impl Drop for Reservation { + fn drop(&mut self) { + self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +/// Borrow encoded bytes through the frame, so retaining the data also retains its admission lease. +pub struct RenderFrame { + pub info: FrameInfo, + png: Vec, + _reservation: Reservation, +} +impl RenderFrame { + pub fn png(&self) -> &[u8] { + &self.png + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct JobStatus { + pub job: JobId, + pub scene_generation: u64, + pub request: ImageRequest, + pub resolved: crate::ResolvedRequest, + pub progress: Progress, + pub error: Option, + pub frame: Option, +} + +struct Data { + progress: Progress, + error: Option, + frame: Option>, +} +struct Record { + id: JobId, + scene_generation: u64, + request: ImageRequest, + resolved: crate::ResolvedRequest, + cancel: CancelToken, + data: Mutex, + #[cfg(not(target_arch = "wasm32"))] + done: Condvar, +} + +impl Record { + fn notify(&self) { + #[cfg(not(target_arch = "wasm32"))] + self.done.notify_all(); + } +} + +#[derive(Clone)] +pub struct JobHandle(Arc); + +impl JobHandle { + pub fn id(&self) -> JobId { + self.0.id + } + pub fn status(&self) -> Result { + let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?; + Ok(JobStatus { + job: self.0.id, + scene_generation: self.0.scene_generation, + request: self.0.request.clone(), + resolved: self.0.resolved.clone(), + progress: data.progress, + error: data.error.clone(), + frame: data.frame.as_ref().map(|f| f.info.clone()), + }) + } + pub fn cancel(&self) -> Result { + { + let mut data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?; + if !data.progress.phase.terminal() { + self.0.cancel.cancel(); + data.progress.phase = if data.progress.phase == Phase::Queued { Phase::Cancelled } else { Phase::Cancelling }; + } + } + self.0.notify(); + self.status() + } + + /// Native application helper; engine status commands remain non-blocking. + #[cfg(not(target_arch = "wasm32"))] + pub fn wait_timeout(&self, timeout: std::time::Duration) -> Result { + let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?; + let (data, timed) = self + .0 + .done + .wait_timeout_while(data, timeout, |d| !d.progress.phase.terminal()) + .map_err(|_| JobError::new(ErrorCode::Internal, "render completion lock poisoned"))?; + if timed.timed_out() && !data.progress.phase.terminal() { + return Err(JobError::new(ErrorCode::TimedOut, "render wait timed out")); + } + drop(data); + self.status() + } + pub fn frame(&self) -> Result>> { + let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render result lock poisoned"))?; + let frame = data.frame.clone(); + if frame.as_ref().is_some_and(|f| f.info.job != self.0.id || f.info.scene_generation != self.0.scene_generation) { + return Err(JobError::new(ErrorCode::WrongOwner, "render frame identity does not match its job")); + } + Ok(frame) + } +} + +struct Work { + scene: ProductScene, + handle: JobHandle, + reservation: Reservation, +} +struct Running { + task: ImageTask, + handle: JobHandle, + reservation: Option, +} + +fn terminal(handle: &JobHandle, error: JobError) { + if let Ok(mut data) = handle.0.data.lock() { + if data.progress.phase == Phase::Completed { + return; + } + if handle.0.cancel.is_cancelled() || error.code == ErrorCode::Cancelled { + data.progress.phase = Phase::Cancelled; + data.error = None; + } else { + data.progress.phase = Phase::Failed; + data.error = Some(error); + } + data.frame = None; + } + handle.0.notify(); +} + +impl Running { + fn begin(work: Work) -> Option { + if work.handle.0.cancel.is_cancelled() { + terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render cancelled")); + return None; + } + if let Ok(mut data) = work.handle.0.data.lock() + && !work.handle.0.cancel.is_cancelled() + { + data.progress.phase = Phase::Preparing; + } + let task = ImageTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone()); + match task { + Ok(task) => Some(Self { task, handle: work.handle, reservation: Some(work.reservation) }), + Err(e) => { + terminal(&work.handle, e); + None + } + } + } + + fn advance(&mut self, units: usize) -> Result { + let result = self.task.step(units)?; + let mut data = self.handle.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?; + // The final publication check is under the same lock as cancellation. + if self.handle.0.cancel.is_cancelled() { + data.progress.phase = Phase::Cancelled; + data.frame = None; + self.handle.0.notify(); + return Ok(false); + } + match result { + TaskPoll::Pending(progress) => { + data.progress = progress; + Ok(true) + } + TaskPoll::Complete(png) => { + let mut reservation = self.reservation.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing render reservation"))?; + reservation.shrink(png.capacity() as u64 + 1024)?; + let info = FrameInfo { + job: self.handle.0.id, + scene_generation: self.handle.0.scene_generation, + backend: self.handle.0.resolved.backend.clone(), + executor: self.handle.0.resolved.executor.clone(), + width: self.handle.0.request.width, + height: self.handle.0.request.height, + encoding: ColourEncoding::Srgb, + alpha: AlphaConvention::Straight, + }; + data.frame = Some(Arc::new(RenderFrame { info, png, _reservation: reservation })); + data.progress = Progress { phase: Phase::Completed, done: 1, total: 1 }; + self.handle.0.notify(); + Ok(false) + } + } + } + + fn guarded_step(&mut self, units: usize) -> bool { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.advance(units))); + match result { + Ok(Ok(more)) => more, + Ok(Err(e)) => { + terminal(&self.handle, e); + false + } + Err(_) => { + terminal(&self.handle, JobError::new(ErrorCode::Internal, "CPU image host encountered an internal error")); + false + } + } + } +} + +struct Shared { + queue: Mutex>, + active: Mutex>>, + stop: AtomicBool, + budget: Arc, + #[cfg(not(target_arch = "wasm32"))] + wake: Condvar, +} + +struct Owner { + shared: Arc, + kind: HostKind, + running: Mutex>, + #[cfg(not(target_arch = "wasm32"))] + _worker: Option>, +} + +impl Drop for Owner { + fn drop(&mut self) { + self.shared.stop.store(true, Ordering::Release); + if let Ok(active) = self.shared.active.lock() + && let Some(record) = active.as_ref().and_then(Weak::upgrade) + { + let _ = JobHandle(record).cancel(); + } + if let Ok(mut queue) = self.shared.queue.lock() { + for work in queue.iter() { + let _ = work.handle.cancel(); + } + queue.clear(); + } + if let Ok(mut running) = self.running.lock() + && let Some(work) = running.take() + { + let _ = work.handle.cancel(); + terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render host closed")); + } + #[cfg(not(target_arch = "wasm32"))] + self.shared.wake.notify_all(); + // Detaching cannot stall the UI; the bounded worker observes stop/cancel at its next unit. + } +} + +#[derive(Clone)] +pub struct CpuHost(Arc); + +impl CpuHost { + fn shared() -> Arc { + Arc::new(Shared { + queue: Mutex::new(VecDeque::new()), + active: Mutex::new(None), + stop: AtomicBool::new(false), + budget: Arc::new(Budget { used: AtomicU64::new(0) }), + #[cfg(not(target_arch = "wasm32"))] + wake: Condvar::new(), + }) + } + + pub fn cooperative() -> Self { + Self(Arc::new(Owner { + shared: Self::shared(), + kind: HostKind::Cooperative, + running: Mutex::new(None), + #[cfg(not(target_arch = "wasm32"))] + _worker: None, + })) + } + + pub fn for_platform() -> Result { + #[cfg(target_arch = "wasm32")] + { + Ok(Self::cooperative()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let shared = Self::shared(); + let worker_shared = shared.clone(); + let worker = std::thread::Builder::new() + .name("solidcraft-render".into()) + .spawn(move || native_worker(worker_shared)) + .map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start CPU image worker: {e}")))?; + Ok(Self(Arc::new(Owner { shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) }))) + } + } + + pub fn kind(&self) -> HostKind { + self.0.kind + } + + pub fn same_host(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } + pub fn is_available(&self) -> bool { + !self.0.shared.stop.load(Ordering::Acquire) + } + pub fn admitted_bytes(&self) -> u64 { + self.0.shared.budget.used.load(Ordering::Acquire) + } + + pub fn descriptor(&self) -> Result { + Ok(ExecutorDescriptor { + id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?, + availability: if self.0.shared.stop.load(Ordering::Acquire) { + Availability::Unavailable { reason: "CPU image host has stopped".into() } + } else { + Availability::Available + }, + profiles: vec![RenderProfile { + mode: RenderMode::Preview, + operation: RenderOperation::Image, + formats: vec![RenderFormat::Png], + features: vec![crate::RenderFeature::Pbr], + }], + limits: RenderLimits::default(), + scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 }, + }) + } + + pub fn submit(&self, id: JobId, scene_generation: u64, scene: ProductScene, request: ImageRequest) -> Result { + if id.session == 0 || id.serial == 0 || id.generation == 0 { + return Err(JobError::new(ErrorCode::InvalidRequest, "invalid render job identity")); + } + let Admission { estimated_total_bytes, .. } = admit(&scene, &request)?; + let mut backend = crate::builtin_backend(); + backend.executors.push(self.descriptor()?); + let resolved = crate::resolve_request( + &[backend], + &crate::RenderRequest { + backend: None, + mode: RenderMode::Preview, + operation: RenderOperation::Image, + width: u64::from(request.width), + height: u64::from(request.height), + format: Some(RenderFormat::Png), + features: scene.required_features(), + policy: Default::default(), + }, + ) + .map_err(|e| JobError::new(ErrorCode::HostUnavailable, e.to_string()))?; + if resolved.executor.as_str() != "cpu-product" { + return Err(JobError::new(ErrorCode::HostUnavailable, "CPU product executor is unavailable")); + } + let mut queue = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?; + if self.0.shared.stop.load(Ordering::Acquire) { + return Err(JobError::new(ErrorCode::HostUnavailable, "CPU image host has stopped")); + } + queue.retain(|work| !work.handle.0.cancel.is_cancelled()); + if queue.len() >= QUEUE_LIMIT { + return Err(JobError::new(ErrorCode::QueueFull, "CPU image queue is full")); + } + let reservation = self.0.shared.budget.reserve(estimated_total_bytes)?; + let handle = JobHandle(Arc::new(Record { + id, + scene_generation, + request, + resolved, + cancel: CancelToken::default(), + data: Mutex::new(Data { progress: Progress { phase: Phase::Queued, done: 0, total: 1 }, error: None, frame: None }), + #[cfg(not(target_arch = "wasm32"))] + done: Condvar::new(), + })); + queue.push_back(Work { scene, handle: handle.clone(), reservation }); + drop(queue); + #[cfg(not(target_arch = "wasm32"))] + self.0.shared.wake.notify_one(); + Ok(handle) + } + + /// Cooperative callers pump a small quantum independently of polling a job's status. + /// Native jobs run even when the UI is idle or a headless caller is not polling. + pub fn pump(&self, units: usize) -> Result { + if self.0.kind != HostKind::Cooperative { + return Ok(false); + } + let mut running = self.0.running.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "cooperative host lock poisoned"))?; + if running.is_none() { + let work = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.pop_front(); + if let Some(work) = work { + *running = Running::begin(work); + } + } + if let Some(work) = running.as_mut() { + if let Ok(mut active) = self.0.shared.active.lock() { + *active = Some(Arc::downgrade(&work.handle.0)); + } + if !work.guarded_step(units) { + *running = None; + if let Ok(mut active) = self.0.shared.active.lock() { + *active = None; + } + } + } + Ok(running.is_some() + || !self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.is_empty()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn native_worker(shared: Arc) { + loop { + let mut queue = match shared.queue.lock() { + Ok(q) => q, + Err(_) => { + shared.stop.store(true, Ordering::Release); + return; + } + }; + while queue.is_empty() && !shared.stop.load(Ordering::Acquire) { + queue = match shared.wake.wait(queue) { + Ok(q) => q, + Err(_) => { + shared.stop.store(true, Ordering::Release); + return; + } + }; + } + if shared.stop.load(Ordering::Acquire) { + return; + } + let work = queue.pop_front(); + drop(queue); + let Some(mut running) = work.and_then(Running::begin) else { continue }; + if let Ok(mut active) = shared.active.lock() { + *active = Some(Arc::downgrade(&running.handle.0)); + } + loop { + if shared.stop.load(Ordering::Acquire) { + let _ = running.handle.cancel(); + } + if !running.guarded_step(4096) { + break; + } + } + if let Ok(mut active) = shared.active.lock() { + *active = None; + } + } +} diff --git a/crates/render/src/job/host_tests.rs b/crates/render/src/job/host_tests.rs new file mode 100644 index 00000000..cb5634f9 --- /dev/null +++ b/crates/render/src/job/host_tests.rs @@ -0,0 +1,131 @@ +use super::*; +use solidcraft_geom::{Aabb3, Mesh}; + +fn request() -> ImageRequest { + ImageRequest { camera: Default::default(), width: 16, height: 16, background: [40, 80, 120], transparent_background: false } +} +fn empty() -> ProductScene { + ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY } +} +fn id(owner: u64, serial: u64) -> JobId { + JobId { session: owner, serial, generation: serial } +} +fn drain(host: &CpuHost) { + for _ in 0..10_000 { + if !host.pump(4096).unwrap() { + return; + } + } + panic!("small cooperative jobs did not drain"); +} + +#[test] +fn queue_is_bounded_and_cancelled_entries_release_their_reservations() { + let host = CpuHost::cooperative(); + let a = host.submit(id(10, 1), 100, empty(), request()).unwrap(); + let b = host.submit(id(20, 1), 200, empty(), request()).unwrap(); + let before = host.admitted_bytes(); + assert_eq!(host.submit(id(30, 1), 300, empty(), request()).err().unwrap().code, ErrorCode::QueueFull); + assert_eq!(host.admitted_bytes(), before); + assert_eq!(a.cancel().unwrap().progress.phase, Phase::Cancelled); + let c = host.submit(id(30, 1), 300, empty(), request()).unwrap(); + drain(&host); + assert_eq!(a.status().unwrap().progress.phase, Phase::Cancelled); + for (handle, owner, generation) in [(&b, 20, 200), (&c, 30, 300)] { + let frame = handle.frame().unwrap().unwrap(); + assert_eq!(frame.info.job, id(owner, 1)); + assert_eq!(frame.info.scene_generation, generation); + assert_eq!(frame.info.executor.as_str(), "cpu-product"); + assert_eq!(image::load_from_memory(frame.png()).unwrap().width(), 16); + } + assert!(host.admitted_bytes() > 0 && host.admitted_bytes() < before); + let frame = b.frame().unwrap().unwrap(); + drop(b); + drop(c); + assert!(host.admitted_bytes() > 0, "an externally retained frame retains its lease"); + drop(frame); + assert_eq!(host.admitted_bytes(), 0); +} + +#[test] +fn cancellation_while_encoding_cannot_publish_a_late_frame() { + let host = CpuHost::cooperative(); + let job = host.submit(id(1, 1), 8, empty(), request()).unwrap(); + for _ in 0..10_000 { + host.pump(64).unwrap(); + let status = job.status().unwrap(); + if status.progress.phase == Phase::Encoding && status.progress.done > 0 { + break; + } + } + assert_eq!(job.status().unwrap().progress.phase, Phase::Encoding); + assert_eq!(job.cancel().unwrap().progress.phase, Phase::Cancelling); + drain(&host); + assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled); + assert!(job.frame().unwrap().is_none()); + assert_eq!(host.admitted_bytes(), 0); +} + +#[test] +fn malformed_preparation_fails_the_job_without_stopping_the_host() { + let host = CpuHost::cooperative(); + let scene = ProductScene { + bounds: Aabb3::EMPTY, + meshes: vec![crate::ProductMesh { + name: "Broken".into(), + mesh: Arc::new(Mesh { triangles: vec![[99, 99, 99]], ..Default::default() }), + color: [0; 3], + opacity: 1.0, + surface: None, + faces: Vec::new(), + }], + }; + let broken = host.submit(id(1, 1), 1, scene, request()).unwrap(); + drain(&host); + let status = broken.status().unwrap(); + assert_eq!(status.progress.phase, Phase::Failed); + assert_eq!(status.error.unwrap().code, ErrorCode::InvalidScene); + assert!(broken.frame().unwrap().is_none()); + assert!(host.is_available()); + let healthy = host.submit(id(1, 2), 1, empty(), request()).unwrap(); + drain(&host); + assert_eq!(healthy.status().unwrap().progress.phase, Phase::Completed); +} + +#[test] +fn closing_the_host_cancels_active_and_queued_work() { + let host = CpuHost::cooperative(); + let budget = host.0.shared.budget.clone(); + let active = host.submit(id(1, 1), 1, empty(), request()).unwrap(); + let queued = host.submit(id(2, 1), 1, empty(), request()).unwrap(); + host.pump(1).unwrap(); + drop(host); + assert_eq!(active.status().unwrap().progress.phase, Phase::Cancelled); + assert_eq!(queued.status().unwrap().progress.phase, Phase::Cancelled); + assert_eq!(budget.used.load(Ordering::Acquire), 0); +} + +#[test] +fn a_foreign_frame_is_rejected_even_if_a_broken_host_puts_it_in_the_result_slot() { + let host = CpuHost::cooperative(); + let job = host.submit(id(1, 1), 1, empty(), request()).unwrap(); + drain(&host); + { + let mut data = job.0.data.lock().unwrap(); + let frame = Arc::get_mut(data.frame.as_mut().unwrap()).unwrap(); + frame.info.job.session = 2; + } + assert_eq!(job.frame().err().unwrap().code, ErrorCode::WrongOwner); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_job_finishes_without_any_poll_or_ui_pump() { + let host = CpuHost::for_platform().unwrap(); + assert_eq!(host.kind(), HostKind::NativeWorker); + let job = host.submit(id(1, 1), 12, empty(), request()).unwrap(); + // A generous deadlock watchdog, not a performance assertion. + let status = job.wait_timeout(std::time::Duration::from_secs(60)).unwrap(); + assert_eq!(status.progress.phase, Phase::Completed); + assert_eq!(job.frame().unwrap().unwrap().info.scene_generation, 12); +} diff --git a/crates/render/src/job/image.rs b/crates/render/src/job/image.rs new file mode 100644 index 00000000..48b91379 --- /dev/null +++ b/crates/render/src/job/image.rs @@ -0,0 +1,700 @@ +//! Incremental product rasterization. Every phase yields between bounded work units. + +use std::collections::BTreeMap; +use std::io::Write; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use solidcraft_geom::Vec3; + +use super::{CancelToken, ErrorCode, ImageRequest, JobError, Phase, Progress, Result}; +use crate::{FaceAppearance, Mat4, ProductScene, RenderFormat, RenderMode, RenderOperation, RenderRequest}; + +#[cfg(test)] +#[path = "image_tests.rs"] +mod tests; + +pub const MAX_TRIANGLES: u64 = 262_144; +pub const MAX_GEOMETRY_BYTES: u64 = 128 * 1024 * 1024; +pub const MAX_JOB_BYTES: u64 = 384 * 1024 * 1024; +pub const MAX_PIXEL_TESTS: u64 = 128_000_000; +const MAX_STEP: usize = 16_384; +const SS: usize = 2; + +#[derive(Clone, Copy, Debug, Serialize)] +pub struct Admission { + pub triangles: u64, + pub face_appearances: u64, + pub geometry_bytes: u64, + pub pixel_scratch_bytes: u64, + pub preparation_bytes: u64, + pub estimated_total_bytes: u64, + pub encoded_limit: u64, +} + +fn limit(message: &str) -> JobError { + JobError::new(ErrorCode::LimitExceeded, message) +} +fn invalid(message: &str) -> JobError { + JobError::new(ErrorCode::InvalidScene, message) +} +fn add_bytes(sum: &mut u64, count: usize, size: usize) -> Result<()> { + let bytes = (count as u64).checked_mul(size as u64).ok_or_else(|| limit("geometry size overflow"))?; + *sum = sum.checked_add(bytes).ok_or_else(|| limit("geometry size overflow"))?; + if *sum > MAX_GEOMETRY_BYTES { + return Err(limit("retained product geometry exceeds 128 MiB")); + } + Ok(()) +} + +/// Conservative admission before allocating pixels or projected-work lists. Source Arcs are +/// counted even when another owner already holds them. Allocator and codec internals are estimates. +pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result { + if !request.camera.is_valid() { + return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera")); + } + if scene.meshes.len() > 16_384 { + return Err(limit("too many product bodies")); + } + let resolved = crate::resolve_request( + &[crate::builtin_backend()], + &RenderRequest { + backend: None, + mode: RenderMode::Preview, + operation: RenderOperation::Image, + width: u64::from(request.width), + height: u64::from(request.height), + format: Some(RenderFormat::Png), + features: Vec::new(), + policy: Default::default(), + }, + ) + .map_err(|e| JobError::new(ErrorCode::InvalidRequest, e.to_string()))?; + let (mut triangles, mut faces, mut geometry, mut polylines) = (0u64, 0u64, 0u64, 0u64); + add_bytes(&mut geometry, scene.meshes.capacity(), std::mem::size_of::())?; + for body in &scene.meshes { + let m = &body.mesh; + triangles = triangles.checked_add(m.triangles.len() as u64).ok_or_else(|| limit("triangle count overflow"))?; + faces = faces.checked_add(body.faces.len() as u64).ok_or_else(|| limit("appearance count overflow"))?; + polylines = polylines.checked_add(m.edges.len() as u64 + m.edge_faces.len() as u64).ok_or_else(|| limit("edge count overflow"))?; + if triangles > MAX_TRIANGLES || faces > MAX_TRIANGLES || polylines > MAX_TRIANGLES { + return Err(limit("product image exceeds the 262144 triangle/appearance/polyline admission cap")); + } + add_bytes(&mut geometry, m.positions.capacity(), std::mem::size_of::())?; + add_bytes(&mut geometry, m.normals.capacity(), std::mem::size_of::())?; + add_bytes(&mut geometry, m.triangles.capacity(), std::mem::size_of::<[u32; 3]>())?; + add_bytes(&mut geometry, m.tri_face.capacity(), std::mem::size_of::())?; + add_bytes(&mut geometry, body.faces.capacity(), std::mem::size_of::())?; + add_bytes(&mut geometry, body.name.capacity(), 1)?; + add_bytes(&mut geometry, m.edges.capacity(), std::mem::size_of::>())?; + add_bytes(&mut geometry, m.edge_faces.capacity(), std::mem::size_of::>())?; + for edge in &m.edges { + add_bytes(&mut geometry, edge.capacity(), std::mem::size_of::())?; + } + for edge in &m.edge_faces { + add_bytes(&mut geometry, edge.capacity(), std::mem::size_of::())?; + } + for count in [m.seams.capacity(), m.smooth_edges.capacity(), m.sharp_edges.capacity()] { + add_bytes(&mut geometry, count, 1)?; + } + if body.surface.is_some() && body.opacity != 1.0 { + return Err(invalid("opaque PBR requires opacity 1")); + } + if !body.opacity.is_finite() || !(0.0..=1.0).contains(&body.opacity) { + return Err(invalid("invalid body opacity")); + } + } + // Three bounded 16-byte lists, a conservative map-node allowance, and codec overhead. + let preparation = triangles + .checked_mul(48) + .and_then(|n| faces.checked_mul(160).and_then(|f| n.checked_add(f))) + .and_then(|n| n.checked_add(scene.meshes.len() as u64 * 128 + 262_144)) + .ok_or_else(|| limit("work list size overflow"))?; + let total = geometry + .checked_add(preparation) + .and_then(|n| n.checked_add(resolved.estimated_scratch_bytes)) + .ok_or_else(|| limit("image memory overflow"))?; + if total > MAX_JOB_BYTES { + return Err(limit("product job exceeds its 384 MiB admission estimate")); + } + let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536; + Ok(Admission { + triangles, + face_appearances: faces, + geometry_bytes: geometry, + pixel_scratch_bytes: resolved.estimated_scratch_bytes, + preparation_bytes: preparation, + estimated_total_bytes: total, + encoded_limit, + }) +} + +fn reserve(count: usize) -> Result> { + let mut v = Vec::new(); + v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve render work memory"))?; + Ok(v) +} + +#[derive(Clone, Copy, Default)] +struct Draw { + body: u32, + triangle: u32, + depth: f64, +} + +impl Draw { + fn compare(&self, other: &Self) -> std::cmp::Ordering { + self.depth.total_cmp(&other.depth).then_with(|| (self.body, self.triangle).cmp(&(other.body, other.triangle))) + } +} + +struct Triangle { + points: [[f64; 3]; 3], + world: [Vec3; 3], + normals: [Vec3; 3], + inv_w: [f64; 3], + colour: [f64; 3], + opacity: f64, + surface: Option, + centre_depth: f64, + area: f64, + bounds: [usize; 4], + x: usize, + y: usize, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Stage { + Looks, + Prepare, + Sort, + Clear, + Opaque, + Glass, + Downsample, + Encode, + Done, +} + +struct BufferWriter { + bytes: Arc>>, + limit: usize, + cancel: CancelToken, +} + +impl Write for BufferWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.cancel.is_cancelled() { + return Err(std::io::Error::other("render cancelled")); + } + let mut out = self.bytes.lock().map_err(|_| std::io::Error::other("PNG output lock poisoned"))?; + if out.len().checked_add(bytes.len()).is_none_or(|n| n > self.limit) { + return Err(std::io::Error::other("PNG output exceeds its byte budget")); + } + out.try_reserve_exact(bytes.len()).map_err(|_| std::io::Error::other("PNG allocation failed"))?; + out.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + if self.cancel.is_cancelled() { Err(std::io::Error::other("render cancelled")) } else { Ok(()) } + } +} + +pub enum TaskPoll { + Pending(Progress), + Complete(Vec), +} + +/// CPU product renderer with cooperative preparation, sorting, raster and PNG encoding. +/// Transparency sorts triangle centres and blends in linear space; it is not refraction. +pub struct ImageTask { + scene: ProductScene, + request: ImageRequest, + admission: Admission, + cancel: CancelToken, + stage: Stage, + vp: Mat4, + back: Vec3, + sw: usize, + sh: usize, + looks: Vec>, + body: usize, + item: usize, + prepare_done: u64, + opaque: Vec, + glass: Vec, + scratch: Vec, + sort_width: usize, + sort_block: usize, + sort_left: usize, + sort_right: usize, + sort_mid: usize, + sort_end: usize, + sort_dst: usize, + pixels: Vec<[u8; 4]>, + depth: Vec, + pixel_tests: u64, + pixel_done: u64, + draw: usize, + triangle: Option, + output: Vec, + encoded: Arc>>, + encoder: Option>, + row: usize, +} + +impl ImageTask { + pub fn new(scene: ProductScene, request: ImageRequest, cancel: CancelToken) -> Result { + cancel.check()?; + let admission = admit(&scene, &request)?; + let (sw, sh) = (request.width as usize * SS, request.height as usize * SS); + let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(request.camera.target); + if !radius.is_finite() { + return Err(invalid("invalid product bounds")); + } + let vp = request.camera.view_proj(sw as f64 / sh as f64, radius); + let back = request.camera.back(); + let looks = reserve(scene.meshes.len())?; + let (opaque, glass, scratch) = + (reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?); + let (pixels, depth, output) = (reserve(sw * sh)?, reserve(sw * sh)?, reserve(request.width as usize * request.height as usize * 4)?); + Ok(Self { + scene, + request, + admission, + cancel, + stage: Stage::Looks, + vp, + back, + sw, + sh, + looks, + body: 0, + item: 0, + prepare_done: 0, + opaque, + glass, + scratch, + sort_width: 1, + sort_block: 0, + sort_left: 0, + sort_right: 0, + sort_mid: 0, + sort_end: 0, + sort_dst: 0, + pixels, + depth, + pixel_tests: 0, + pixel_done: 0, + draw: 0, + triangle: None, + output, + encoded: Arc::new(Mutex::new(Vec::new())), + encoder: None, + row: 0, + }) + } + + pub fn progress(&self) -> Progress { + match self.stage { + Stage::Looks | Stage::Prepare | Stage::Sort => Progress { + phase: Phase::Preparing, + done: self.prepare_done, + total: self.admission.face_appearances + self.admission.triangles + self.scene.meshes.len() as u64 + self.glass.len() as u64 * 19, + }, + Stage::Clear | Stage::Opaque | Stage::Glass | Stage::Downsample => Progress { + phase: Phase::Running, + done: self.pixel_done, + total: self.pixel_tests + (self.sw * self.sh) as u64 + u64::from(self.request.width) * u64::from(self.request.height), + }, + Stage::Encode => Progress { phase: Phase::Encoding, done: self.row as u64, total: u64::from(self.request.height) }, + Stage::Done => Progress { phase: Phase::Completed, done: 1, total: 1 }, + } + } + + /// At most 16384 simple units; encoding additionally yields after at most four rows. + pub fn step(&mut self, units: usize) -> Result { + let mut rows = 0; + for _ in 0..units.clamp(1, MAX_STEP) { + self.cancel.check()?; + match self.stage { + Stage::Looks => self.look_step()?, + Stage::Prepare => self.prepare_step()?, + Stage::Sort => self.sort_step()?, + Stage::Clear => self.clear_step(), + Stage::Opaque | Stage::Glass => self.draw_step()?, + Stage::Downsample => self.downsample_step()?, + Stage::Encode => { + if let Some(png) = self.encode_step()? { + return Ok(TaskPoll::Complete(png)); + } + rows += 1; + if rows >= 4 { + break; + } + } + Stage::Done => return Err(JobError::new(ErrorCode::NotCompleted, "render result already consumed")), + } + } + Ok(TaskPoll::Pending(self.progress())) + } + + fn look_step(&mut self) -> Result<()> { + let Some(body) = self.scene.meshes.get(self.body) else { + self.body = 0; + self.item = 0; + self.stage = Stage::Prepare; + return Ok(()); + }; + if self.looks.len() == self.body { + self.looks.push(BTreeMap::new()); + } + if let Some(face) = body.faces.get(self.item) { + if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) { + return Err(invalid("invalid face opacity")); + } + let looks = self.looks.get_mut(self.body).ok_or_else(|| invalid("missing body appearances"))?; + // Match the document policy: the last assignment of a face wins. + looks.insert(face.face, face.clone()); + self.item += 1; + } else { + self.body += 1; + self.item = 0; + } + self.prepare_done += 1; + Ok(()) + } + + fn project(&self, draw: Draw) -> Result> { + let body = self.scene.meshes.get(draw.body as usize).ok_or_else(|| invalid("missing product body"))?; + let mesh = &body.mesh; + let indices = mesh.triangles.get(draw.triangle as usize).ok_or_else(|| invalid("missing product triangle"))?; + let vertices = indices.map(|i| mesh.positions.get(i as usize).copied()); + let normals = indices.map(|i| mesh.normals.get(i as usize).copied()); + let ([Some(a), Some(b), Some(c)], [Some(na), Some(nb), Some(nc)]) = (vertices, normals) else { + return Err(invalid("invalid triangle index or missing normal")); + }; + let world = [a, b, c]; + let normals = [na, nb, nc]; + if world.iter().chain(normals.iter()).any(|v| !v.is_finite()) { + return Err(invalid("non-finite product triangle")); + } + let look = self.looks.get(draw.body as usize).and_then(|looks| mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face))); + let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface), |look| (look.color, look.opacity, look.surface)); + if surface.is_some() && opacity != 1.0 { + return Err(invalid("opaque PBR requires opacity 1")); + } + let normals = if surface.is_some() { + [crate::surface::unit_direction(na), crate::surface::unit_direction(nb), crate::surface::unit_direction(nc)] + .map(|n| n.map_err(|e| invalid(&e.to_string()))) + } else { + [Ok(normals[0]), Ok(normals[1]), Ok(normals[2])] + }; + let [na, nb, nc] = normals; + let normals = [na?, nb?, nc?]; + if opacity <= 0.0 { + return Ok(None); + } + let clip = world.map(|p| self.vp.apply(p)); + if clip.iter().flatten().any(|v| !v.is_finite()) { + return Err(invalid("non-finite product projection")); + } + if clip.iter().any(|p| p[3] <= 1e-9) { + return Ok(None); + } + let inv_w = clip.map(|p| 1.0 / p[3]); + let points = clip.map(|p| [(p[0] / p[3] + 1.0) * 0.5 * self.sw as f64, (1.0 - p[1] / p[3]) * 0.5 * self.sh as f64, p[2] / p[3]]); + if points.iter().flatten().any(|v| !v.is_finite()) || points.iter().any(|p| !(p[2] as f32).is_finite()) { + return Err(invalid("product projection exceeds pixel precision")); + } + let [a, b, c] = points; + let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(self.sw as f64 - 1.0)); + let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(self.sh as f64 - 1.0)); + let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + if !(x0 <= x1 && y0 <= y1) || area.abs() < 1e-12 { + return Ok(None); + } + if !area.is_finite() { + return Err(invalid("invalid projected area")); + } + let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.request.camera.target).dot(self.back); + if !centre_depth.is_finite() { + return Err(invalid("invalid triangle depth")); + } + Ok(Some(Triangle { + points, + world, + normals, + inv_w, + colour: colour.map(decode), + opacity, + surface, + centre_depth, + area, + bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize], + x: x0 as usize, + y: y0 as usize, + })) + } + + fn prepare_step(&mut self) -> Result<()> { + let Some(body) = self.scene.meshes.get(self.body) else { + self.stage = if self.glass.len() > 1 { Stage::Sort } else { Stage::Clear }; + self.sort_block = 0; + self.init_merge(); + return Ok(()); + }; + if self.item >= body.mesh.triangles.len() { + self.body += 1; + self.item = 0; + return Ok(()); + } + let mut draw = Draw { body: self.body as u32, triangle: self.item as u32, depth: 0.0 }; + if let Some(triangle) = self.project(draw)? { + let [x0, x1, y0, y1] = triangle.bounds; + let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?; + self.pixel_tests = self.pixel_tests.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?; + if self.pixel_tests > MAX_PIXEL_TESTS { + return Err(limit("projected product exceeds 128000000 pixel tests")); + } + draw.depth = triangle.centre_depth; + if triangle.opacity >= 1.0 { + self.opaque.push(draw); + } else { + self.glass.push(draw); + self.scratch.push(draw); + } + } + self.item += 1; + self.prepare_done += 1; + Ok(()) + } + + fn init_merge(&mut self) { + let n = self.glass.len(); + self.sort_mid = (self.sort_block + self.sort_width).min(n); + self.sort_end = (self.sort_block + 2 * self.sort_width).min(n); + self.sort_left = self.sort_block; + self.sort_right = self.sort_mid; + self.sort_dst = self.sort_block; + } + + fn sort_step(&mut self) -> Result<()> { + if self.sort_dst >= self.sort_end { + self.sort_block += 2 * self.sort_width; + if self.sort_block >= self.glass.len() { + std::mem::swap(&mut self.glass, &mut self.scratch); + self.sort_width *= 2; + self.sort_block = 0; + if self.sort_width >= self.glass.len() { + self.stage = Stage::Clear; + return Ok(()); + } + } + self.init_merge(); + } + let left = self.glass.get(self.sort_left).filter(|_| self.sort_left < self.sort_mid); + let right = self.glass.get(self.sort_right).filter(|_| self.sort_right < self.sort_end); + let take_left = match (left, right) { + (Some(a), Some(b)) => a.compare(b).is_le(), + (Some(_), None) => true, + _ => false, + }; + let source = if take_left { + let i = self.sort_left; + self.sort_left += 1; + i + } else { + let i = self.sort_right; + self.sort_right += 1; + i + }; + let draw = self.glass.get(source).copied().ok_or_else(|| invalid("invalid transparent sort source"))?; + *self.scratch.get_mut(self.sort_dst).ok_or_else(|| invalid("invalid transparent sort destination"))? = draw; + self.sort_dst += 1; + self.prepare_done += 1; + Ok(()) + } + + fn clear_step(&mut self) { + if self.pixels.len() >= self.sw * self.sh { + self.stage = Stage::Opaque; + self.draw = 0; + return; + } + let [r, g, b] = self.request.background; + self.pixels.push(if self.request.transparent_background { [0; 4] } else { [r, g, b, 255] }); + self.depth.push(f32::INFINITY); + self.pixel_done += 1; + } + + fn draw_step(&mut self) -> Result<()> { + if self.triangle.is_none() { + let list = if self.stage == Stage::Opaque { &self.opaque } else { &self.glass }; + let Some(draw) = list.get(self.draw).copied() else { + self.stage = if self.stage == Stage::Opaque { Stage::Glass } else { Stage::Downsample }; + self.draw = 0; + return Ok(()); + }; + self.triangle = self.project(draw)?; + if self.triangle.is_none() { + self.draw += 1; + } + return Ok(()); + } + let triangle = self.triangle.as_mut().ok_or_else(|| invalid("missing raster triangle"))?; + let eye = (self.request.camera.fov > 0.0).then(|| self.request.camera.eye()); + raster_pixel(triangle, self.sw, self.back, eye, &mut self.pixels, &mut self.depth)?; + triangle.x += 1; + if triangle.x > triangle.bounds[1] { + triangle.x = triangle.bounds[0]; + triangle.y += 1; + } + if triangle.y > triangle.bounds[3] { + self.triangle = None; + self.draw += 1; + } + self.pixel_done += 1; + Ok(()) + } + + fn downsample_step(&mut self) -> Result<()> { + let width = self.request.width as usize; + let index = self.output.len() / 4; + if index >= width * self.request.height as usize { + let writer = BufferWriter { bytes: self.encoded.clone(), limit: self.admission.encoded_limit as usize, cancel: self.cancel.clone() }; + let mut encoder = png::Encoder::new(writer, self.request.width, self.request.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); + self.encoder = Some( + encoder.write_header().and_then(|w| w.into_stream_writer()).map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?, + ); + self.stage = Stage::Encode; + // Pixels and prepared geometry are no longer needed during PNG compression. + self.pixels = Vec::new(); + self.depth = Vec::new(); + self.opaque = Vec::new(); + self.glass = Vec::new(); + self.scratch = Vec::new(); + self.looks = Vec::new(); + self.scene.meshes.clear(); + return Ok(()); + } + let (x, y) = (index % width, index / width); + let (mut colour, mut alpha) = ([0.0; 3], 0.0); + for dy in 0..SS { + for dx in 0..SS { + let p = self.pixels.get((y * SS + dy) * self.sw + x * SS + dx).ok_or_else(|| invalid("missing downsample pixel"))?; + let a = f64::from(p[3]) / 255.0; + for (sum, channel) in colour.iter_mut().zip(p.iter().take(3)) { + *sum += decode(*channel) * a; + } + alpha += a; + } + } + let rgb = colour.map(|v| if alpha > 1e-12 { encode(v / alpha) } else { 0 }); + self.output.extend_from_slice(&[rgb[0], rgb[1], rgb[2], (alpha * 255.0 / (SS * SS) as f64).round() as u8]); + self.pixel_done += 1; + Ok(()) + } + + fn encode_step(&mut self) -> Result>> { + if self.row < self.request.height as usize { + let length = self.request.width as usize * 4; + let start = self.row * length; + let row = self.output.get(start..start + length).ok_or_else(|| invalid("missing PNG row"))?; + self.encoder + .as_mut() + .ok_or_else(|| invalid("missing PNG writer"))? + .write_all(row) + .map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?; + self.row += 1; + return Ok(None); + } + self.encoder + .take() + .ok_or_else(|| invalid("missing PNG writer"))? + .finish() + .map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?; + self.cancel.check()?; + let mut bytes = self.encoded.lock().map_err(|_| JobError::new(ErrorCode::Internal, "PNG output lock poisoned"))?; + let png = std::mem::take(&mut *bytes); + if !png.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(JobError::new(ErrorCode::EncodingFailed, "invalid PNG output")); + } + self.output = Vec::new(); + self.stage = Stage::Done; + Ok(Some(png)) + } +} + +fn raster_pixel(t: &Triangle, width: usize, back: Vec3, eye: Option, pixels: &mut [[u8; 4]], depth: &mut [f32]) -> Result<()> { + let [a, b, c] = t.points; + let (x, y) = (t.x as f64 + 0.5, t.y as f64 + 0.5); + let w0 = ((b[0] - x) * (c[1] - y) - (b[1] - y) * (c[0] - x)) / t.area; + let w1 = ((c[0] - x) * (a[1] - y) - (c[1] - y) * (a[0] - x)) / t.area; + let w2 = 1.0 - w0 - w1; + // A shared triangle edge belongs to exactly one raster triangle, including glass. + for (w, mut p, mut q) in [(w0, b, c), (w1, c, a), (w2, a, b)] { + if w < -1e-9 { + return Ok(()); + } + if w.abs() <= 1e-9 { + if t.area < 0.0 { + std::mem::swap(&mut p, &mut q); + } + let (dx, dy) = (q[0] - p[0], q[1] - p[1]); + if !(dy < 0.0 || (dy == 0.0 && dx > 0.0)) { + return Ok(()); + } + } + } + let z = (w0 * a[2] + w1 * b[2] + w2 * c[2]) as f32; + let index = t.y * width + t.x; + let old_depth = depth.get_mut(index).ok_or_else(|| invalid("missing pixel depth"))?; + if z > *old_depth { + return Ok(()); + } + let weights = [w0 * t.inv_w[0], w1 * t.inv_w[1], w2 * t.inv_w[2]]; + let sum: f64 = weights.iter().sum(); + if !sum.is_finite() || sum <= 1e-12 { + return Ok(()); + } + let n = t.normals.iter().zip(weights).fold(Vec3::ZERO, |v, (n, w)| v + *n * (w / sum)).normalized().unwrap_or(Vec3::Z); + let colour = if let Some(surface) = t.surface { + let world = t.world.iter().zip(weights).fold(Vec3::ZERO, |p, (v, w)| p + *v * (w / sum)); + let view = eye.map_or(back, |eye| eye - world); + let radiance = crate::studio::shade(surface, n, view).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 { + crate::raster::shade(n, back, t.colour) + }; + if colour.iter().any(|v| !v.is_finite()) { + return Err(invalid("non-finite shaded colour")); + } + let pixel = pixels.get_mut(index).ok_or_else(|| invalid("missing colour pixel"))?; + if t.opacity >= 1.0 { + *old_depth = z; + *pixel = [encode(colour[0]), encode(colour[1]), encode(colour[2]), 255]; + } else { + let old_alpha = f64::from(pixel[3]) / 255.0; + let alpha = t.opacity + old_alpha * (1.0 - t.opacity); + for (out, src) in pixel.iter_mut().take(3).zip(colour) { + *out = encode((src * t.opacity + decode(*out) * old_alpha * (1.0 - t.opacity)) / alpha); + } + pixel[3] = (alpha * 255.0).round() as u8; + } + Ok(()) +} + +fn decode(v: u8) -> f64 { + let s = f64::from(v) / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } +} +fn encode(v: f64) -> u8 { + let l = v.clamp(0.0, 1.0); + let s = if l <= 0.0031308 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 }; + (s * 255.0).round() as u8 +} diff --git a/crates/render/src/job/image_tests.rs b/crates/render/src/job/image_tests.rs new file mode 100644 index 00000000..18406e1f --- /dev/null +++ b/crates/render/src/job/image_tests.rs @@ -0,0 +1,147 @@ +use super::*; +use crate::{Camera, ProductMesh, StandardView}; +use solidcraft_geom::{Aabb3, Mesh}; + +fn request() -> ImageRequest { + let mut camera = Camera { distance: 50.0, ..Default::default() }; + camera.set_view(StandardView::Front); + ImageRequest { camera, width: 64, height: 64, background: [20, 40, 60], transparent_background: true } +} + +fn square(name: &str, colour: [u8; 3], opacity: f64, offset: Vec3) -> ProductMesh { + let mesh = Mesh { + positions: vec![ + Vec3::new(-10.0, 0.0, -10.0) + offset, + Vec3::new(10.0, 0.0, -10.0) + offset, + Vec3::new(10.0, 0.0, 10.0) + offset, + Vec3::new(-10.0, 0.0, 10.0) + offset, + ], + normals: vec![Vec3::new(0.0, -1.0, 0.0); 4], + triangles: vec![[0, 1, 2], [0, 2, 3]], + tri_face: vec![0, 1], + ..Default::default() + }; + ProductMesh { name: name.into(), mesh: Arc::new(mesh), color: colour, opacity, surface: None, faces: Vec::new() } +} + +fn scene(meshes: Vec) -> ProductScene { + let bounds = meshes.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.mesh.bounds())); + ProductScene { meshes, bounds } +} + +fn finish(mut task: ImageTask) -> Vec { + for _ in 0..100_000 { + match task.step(4096).unwrap() { + TaskPoll::Complete(png) => return png, + TaskPoll::Pending(p) => assert!(p.done <= p.total, "{p:?}"), + } + } + panic!("bounded fixture did not finish"); +} + +#[test] +fn transparent_png_has_straight_srgb_colour_without_a_shared_edge_seam() { + let scene = scene(vec![square("Glass", [220, 40, 20], 0.5, Vec3::ZERO)]); + let png = finish(ImageTask::new(scene, request(), CancelToken::default()).unwrap()); + let image = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(image.get_pixel(0, 0).0, [0; 4]); + for (x, y) in [(32, 32), (31, 31), (30, 32), (32, 30)] { + let p = image.get_pixel(x, y).0; + assert!((i16::from(p[3]) - 128).abs() <= 1, "a shared diagonal must not double-blend: {p:?}"); + assert!(p[0] > 180 && p[0] > p[1] + 60, "RGB is straight, not multiplied by 0.5: {p:?}"); + } + let reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap(); + assert_eq!(reader.info().srgb, Some(png::SrgbRenderingIntent::Perceptual)); +} + +#[test] +fn glass_is_sorted_back_to_front_independently_of_body_order() { + let request = request(); + let far = square("Far", [240, 20, 20], 0.5, request.camera.back() * -5.0); + let near = square("Near", [20, 20, 240], 0.5, request.camera.back() * 5.0); + let a = finish(ImageTask::new(scene(vec![far.clone(), near.clone()]), request.clone(), CancelToken::default()).unwrap()); + let b = finish(ImageTask::new(scene(vec![near, far]), request, CancelToken::default()).unwrap()); + assert_eq!(a, b); + let image = image::load_from_memory(&a).unwrap().to_rgba8(); + let p = image.get_pixel(32, 32).0; + assert!((i16::from(p[3]) - 192).abs() <= 1, "two half-transparent layers: {p:?}"); + assert!(p[2] > p[0] + 30, "the near blue layer contributes more: {p:?}"); +} + +#[test] +fn cancellation_is_observed_during_preparation_sort_pixels_and_encoding() { + for stage in [Stage::Looks, Stage::Prepare, Stage::Sort, Stage::Clear, Stage::Glass, Stage::Downsample, Stage::Encode] { + let cancel = CancelToken::default(); + let mut task = ImageTask::new(scene(vec![square("Glass", [40, 180, 100], 0.4, Vec3::ZERO)]), request(), cancel.clone()).unwrap(); + let mut reached = false; + for _ in 0..100_000 { + if task.stage == stage { + reached = true; + break; + } + assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_))); + } + assert!(reached); + cancel.cancel(); + assert_eq!(task.step(4096).err().unwrap().code, ErrorCode::Cancelled); + } +} + +#[test] +fn encoding_yields_between_rows_and_completed_result_is_consumed_once() { + let mut task = ImageTask::new(scene(Vec::new()), request(), CancelToken::default()).unwrap(); + while task.stage != Stage::Encode { + assert!(matches!(task.step(64).unwrap(), TaskPoll::Pending(_))); + } + let before = task.row; + assert!(matches!(task.step(usize::MAX).unwrap(), TaskPoll::Pending(_))); + assert!(task.row - before <= 4); + loop { + if let TaskPoll::Complete(bytes) = task.step(4096).unwrap() { + assert_eq!(image::load_from_memory(&bytes).unwrap().width(), 64); + break; + } + } + assert_eq!(task.step(1).err().unwrap().code, ErrorCode::NotCompleted); +} + +#[test] +fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() { + let body = square("Product", [100, 120, 140], 1.0, Vec3::ZERO); + let mut bad_request = request(); + bad_request.width = u32::MAX; + assert!(ImageTask::new(scene(vec![body.clone()]), bad_request, CancelToken::default()).is_err()); + for bad in [0, 1] { + let mut body = body.clone(); + if bad == 0 { + Arc::make_mut(&mut body.mesh).triangles[0][0] = u32::MAX; + } else { + body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None }); + } + let mut task = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).unwrap(); + let error = loop { + if let Err(error) = task.step(64) { + break error; + } + }; + assert_eq!(error.code, ErrorCode::InvalidScene); + } + let mut body = body; + Arc::make_mut(&mut body.mesh).triangles.resize(MAX_TRIANGLES as usize + 1, [0, 1, 2]); + assert_eq!(ImageTask::new(scene(vec![body]), request(), CancelToken::default()).err().unwrap().code, ErrorCode::LimitExceeded); + assert_eq!(std::mem::size_of::(), 16, "admission accounts for each of the three lists"); +} + +#[test] +fn png_output_budget_and_cancellation_fail_without_partial_publication() { + let bytes = Arc::new(Mutex::new(Vec::new())); + let cancel = CancelToken::default(); + let mut writer = BufferWriter { bytes: bytes.clone(), limit: 4, cancel: cancel.clone() }; + writer.write_all(b"PNG").unwrap(); + assert!(writer.write_all(b"too large").is_err()); + assert_eq!(*bytes.lock().unwrap(), b"PNG"); + cancel.cancel(); + assert!(writer.write_all(b"x").is_err()); + assert!(writer.flush().is_err()); + assert_eq!(*bytes.lock().unwrap(), b"PNG"); +} diff --git a/crates/render/src/job/mod.rs b/crates/render/src/job/mod.rs new file mode 100644 index 00000000..e7f6c45e --- /dev/null +++ b/crates/render/src/job/mod.rs @@ -0,0 +1,140 @@ +//! CPU product-image work and its device-independent, session-scoped protocol. +//! Hosts own execution; documents and history never contain these values. + +use std::fmt; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{BackendId, Camera, ExecutorId}; + +mod host; +mod image; +pub use host::{CpuHost, HostKind, JobHandle, JobStatus, RenderFrame}; +pub use image::{Admission, ImageTask, TaskPoll, admit}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JobId { + pub session: u64, + pub serial: u64, + pub generation: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Queued, + Preparing, + Running, + Encoding, + Cancelling, + Cancelled, + Completed, + Failed, +} + +impl Phase { + pub fn terminal(self) -> bool { + matches!(self, Self::Cancelled | Self::Completed | Self::Failed) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct Progress { + pub phase: Phase, + pub done: u64, + pub total: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + InvalidRequest, + LimitExceeded, + AllocationFailed, + HostUnavailable, + QueueFull, + TimedOut, + Cancelled, + InvalidScene, + EncodingFailed, + WrongOwner, + UnknownJob, + NotCompleted, + Internal, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct JobError { + pub code: ErrorCode, + pub message: String, +} + +impl JobError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { code, message: message.into() } + } +} + +impl fmt::Display for JobError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}: {}", self.code, self.message) + } +} +impl std::error::Error for JobError {} + +pub type Result = std::result::Result; + +#[derive(Clone, Default)] +pub struct CancelToken(Arc); + +impl CancelToken { + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } + pub fn check(&self) -> Result<()> { + if self.is_cancelled() { Err(JobError::new(ErrorCode::Cancelled, "render cancelled")) } else { Ok(()) } + } +} + +/// Fully resolved input; no mutable session, topology graph or GPU resources. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImageRequest { + pub camera: Camera, + pub width: u32, + pub height: u32, + pub background: [u8; 3], + pub transparent_background: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ColourEncoding { + Srgb, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AlphaConvention { + Straight, +} + +#[derive(Clone, Debug, Serialize)] +pub struct FrameInfo { + pub job: JobId, + pub scene_generation: u64, + pub backend: BackendId, + pub executor: ExecutorId, + pub width: u32, + pub height: u32, + pub encoding: ColourEncoding, + pub alpha: AlphaConvention, +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4721f02a..46b4af0d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,8 +7,11 @@ mod backend; mod camera; mod curve_display; +pub mod job; pub mod lighting; +mod product; mod raster; +pub mod studio; pub mod surface; pub mod texture_input; @@ -19,4 +22,5 @@ pub use backend::{ }; pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped}; +pub use product::{FaceAppearance, ProductMesh, ProductScene}; pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent}; diff --git a/crates/render/src/product.rs b/crates/render/src/product.rs new file mode 100644 index 00000000..a72ca68e --- /dev/null +++ b/crates/render/src/product.rs @@ -0,0 +1,42 @@ +//! Renderer-independent product geometry. No modelling overlays or device resources. + +use std::sync::Arc; + +use solidcraft_geom::{Aabb3, Mesh}; + +#[derive(Clone, Debug, PartialEq)] +pub struct FaceAppearance { + /// The existing mesh's B-rep face index, resolved by the document's appearance policy. + pub face: u32, + pub color: [u8; 3], + pub opacity: f64, + pub surface: Option, +} + +#[derive(Clone, Debug)] +pub struct ProductMesh { + /// Existing computed body name, including occurrence identity where applicable. + pub name: String, + /// Established world-space mesh. Adapters must not apply occurrence transforms again. + pub mesh: Arc, + pub color: [u8; 3], + pub opacity: f64, + pub surface: Option, + pub faces: Vec, +} + +#[derive(Clone, Debug)] +pub struct ProductScene { + pub meshes: Vec, + pub bounds: Aabb3, +} + +impl ProductScene { + pub fn required_features(&self) -> Vec { + if self.meshes.iter().any(|m| m.surface.is_some() || m.faces.iter().any(|f| f.surface.is_some())) { + vec![crate::RenderFeature::Pbr] + } else { + Vec::new() + } + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 55b9a1a8..9946ff72 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -79,7 +79,7 @@ impl Canvas { } } -fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] { +pub(crate) fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] { let n = if n.dot(view_dir) < 0.0 { -n } else { n }; let key = (view_dir * 0.8 + Vec3::new(-0.3, 0.2, 0.6)).normalized().unwrap_or(view_dir); let diff = n.dot(key).max(0.0); diff --git a/crates/render/src/studio.rs b/crates/render/src/studio.rs new file mode 100644 index 00000000..4e9aa7eb --- /dev/null +++ b/crates/render/src/studio.rs @@ -0,0 +1,51 @@ +//! Original fixed direct-light studio used by opaque product preview consumers. +//! Relative linear radiance, not a calibrated illuminance or an HDRI environment. + +use solidcraft_geom::Vec3; + +use crate::surface::{OpaqueSurface, SurfaceError, unit_direction}; + +/// Fixed, neutral directional lights in the project's world frame (Z up). +/// Directions point towards each light and are analytically unit length. +pub const LIGHTS: [(Vec3, f64); 3] = [(Vec3::new(0.6, 0.0, 0.8), 3.0), (Vec3::new(-0.8, 0.0, 0.6), 1.0), (Vec3::new(0.0, -0.6, 0.8), 1.5)]; + +/// Sum BRDF * incident radiance * light cosine, without display transfer. +/// There are no cast shadows, indirect bounces, ambient term or emission. +pub fn shade(surface: OpaqueSurface, normal: Vec3, view: Vec3) -> Result { + let n = unit_direction(normal)?; + let mut radiance = Vec3::ZERO; + for (light, intensity) in LIGHTS { + radiance += surface.brdf(n, view, light)? * (intensity * n.dot(light).max(0.0)); + } + Ok(radiance) +} + +/// Per-channel Reinhard preview tone map, still in linear sRGB. +/// Consumers encode exactly once after applying this to nonnegative finite radiance. +pub fn tone_map(radiance: Vec3) -> Result { + if !radiance.is_finite() || radiance.x < 0.0 || radiance.y < 0.0 || radiance.z < 0.0 { + return Err(SurfaceError::Radiance); + } + let map = |c: f64| c / (1.0 + c); + Ok(Vec3::new(map(radiance.x), map(radiance.y), map(radiance.z))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f64::consts::PI; + + #[test] + fn direct_white_conductor_matches_closed_form_and_is_tone_mapped_once() { + let s = OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, 1.0, 1.5).unwrap(); + let c = shade(s, Vec3::Z, Vec3::Z).unwrap(); + let expected = (4.5 * 0.8 / 1.8 + 0.6 / 1.6) / (2.0 * PI); + assert!((c - Vec3::new(expected, expected, expected)).len() < 1e-14); + let mapped = tone_map(c).unwrap(); + assert!((mapped.x - expected / (1.0 + expected)).abs() < 1e-14); + assert_eq!(tone_map(Vec3::ZERO).unwrap(), Vec3::ZERO); + assert!(tone_map(Vec3::new(f64::NAN, 0.0, 0.0)).is_err()); + assert!(tone_map(Vec3::new(-1.0, 0.0, 0.0)).is_err()); + assert!(shade(s, Vec3::ZERO, Vec3::Z).is_err()); + } +} diff --git a/crates/render/src/surface.rs b/crates/render/src/surface.rs index 89f0aa48..a6fba335 100644 --- a/crates/render/src/surface.rs +++ b/crates/render/src/surface.rs @@ -25,6 +25,7 @@ pub enum SurfaceError { Roughness, DielectricIor, Direction, + Radiance, } impl std::fmt::Display for SurfaceError { @@ -35,6 +36,7 @@ impl std::fmt::Display for SurfaceError { Self::Roughness => "perceptual roughness must be finite and in [0.045, 1]", Self::DielectricIor => "dielectric IOR must be finite and in [1, 3]", Self::Direction => "surface directions must be finite and nonzero", + Self::Radiance => "radiance must be finite and nonnegative", }) } } @@ -81,15 +83,15 @@ impl OpaqueSurface { /// No incident radiance, cosine weight, exposure or display transfer is included. /// Back-facing/tangent directions return zero; malformed directions are errors. pub fn brdf(self, normal: Vec3, view: Vec3, light: Vec3) -> Result { - let n = unit(normal)?; - let v = unit(view)?; - let l = unit(light)?; + let n = unit_direction(normal)?; + let v = unit_direction(view)?; + let l = unit_direction(light)?; let nv = n.dot(v).clamp(-1.0, 1.0); let nl = n.dot(l).clamp(-1.0, 1.0); if nv <= 0.0 || nl <= 0.0 { return Ok(Vec3::ZERO); } - let h = unit(v + l)?; + let h = unit_direction(v + l)?; let nh = n.dot(h).clamp(0.0, 1.0); let vh = v.dot(h).clamp(0.0, 1.0); let alpha = self.roughness * self.roughness; @@ -122,7 +124,8 @@ pub fn srgb8_to_linear(channel: u8) -> f64 { if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } -fn unit(v: Vec3) -> Result { +/// Normalize finite directions without overflow or an arbitrary magnitude threshold. +pub fn unit_direction(v: Vec3) -> Result { if !v.is_finite() { return Err(SurfaceError::Direction); } diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index f5c11898..b64f49e0 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -329,8 +329,15 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::ToolbarCommand { id } => crate::toolbar_custom::command_items(app, id), Target::Toolbar => crate::toolbar_custom::toolbar_items(app), Target::Workspace => { - let mut v = vec![act("ui.workspace", "Design", "box").key("current")]; - for w in ["Render", "Animation", "Simulation", "Manufacture", "Drawing"] { + let rendering = crate::render_workspace::active(app); + let mut v = vec![ + act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }), + act("ui.workspace.render", "Render", "camera") + .with(json!({"workspace":"render"})) + .key(if rendering { "current" } else { "" }) + .on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()), + ]; + for w in ["Animation", "Simulation", "Manufacture", "Drawing"] { v.push(act("ui.workspace", w, "").key("coming later").on(false)); } v @@ -975,7 +982,13 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { "ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.toolbarCustomize" | "ui.shortcutPin" => { crate::toolbar_custom::run(app, &item.id, &p) } - "ui.workspace" => {} + "ui.workspace" | "ui.workspace.design" | "ui.workspace.render" => { + if let Some(workspace) = p.get("workspace").and_then(Value::as_str) + && let Err(e) = crate::render_workspace::enter(app, workspace == "render") + { + app.set_status(e, true); + } + } "ui.findJoint" => { if let Some(id) = id_of(&p, "joint") { crate::dialogs_assembly::find_joint(app, id); @@ -1043,6 +1056,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { { k.color = look.color; k.opacity = look.opacity; + k.pbr = look.pbr; k.look = Some(look.name.clone()); } k.component = Some(name); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2a180663..2cea259d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -185,10 +185,22 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome { + crate::render_workspace::sync_backend(app); let s = |k: &str| p.get(k).and_then(Value::as_str); let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()); let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); match method { + "ui.workspace" => { + if let Some(workspace) = s("workspace") { + if !matches!(workspace, "design" | "render") { + return err("workspace must be design or render"); + } + if let Err(e) = crate::render_workspace::enter(app, workspace == "render") { + return err(e); + } + } + ok(json!({"workspace":if crate::render_workspace::active(app) {"render"} else {"design"}})) + } "engine.execute" | "command" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; wrap(execute(app, id, p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})))) @@ -230,6 +242,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> "frame_ms": app.frame_ms, "file_busy": app.file_load.busy(), "file_picker_busy": app.file_picker_busy(), + "file_dialog_open": app.file_picker_busy(), "build_ms": app.viewport.build_ms, "preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced}, "home": app.home.open, @@ -283,6 +296,11 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> } match (serde_json::from_value::(cur), serde_json::from_value::(vis)) { (Ok(u), Ok(v)) => { + if (u.tab == "RENDER") != crate::render_workspace::active(app) + && let Err(e) = crate::render_workspace::enter(app, u.tab == "RENDER") + { + return err(e); + } app.ui = u; app.session.visibility = v; ok(serde_json::to_value(&app.ui).unwrap_or_default()) diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index f1b17e25..b07423cd 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -6,6 +6,7 @@ use egui::{Color32, RichText, Sense, Stroke, vec2}; use serde_json::{Value, json}; use solidcraft_engine::Sel; +use solidcraft_engine::doc::pbr_appearance::PbrParameters; use crate::SolveApp; use crate::dialogs::{Kind, combo, row_label}; @@ -20,11 +21,12 @@ pub struct Look { pub category: String, pub color: [u8; 3], pub opacity: f64, + pub pbr: Option, } /// Built-in looks, used until the engine offers a library (`appearance.library`). fn builtin() -> Vec { - let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o }; + let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o, pbr: None }; vec![ l("Metal", "Aluminum - Satin", [196, 199, 204], 1.0), l("Metal", "Steel - Brushed", [160, 164, 170], 1.0), @@ -64,6 +66,7 @@ pub fn library(app: &mut SolveApp) -> Vec { category: x.get("category").and_then(Value::as_str).unwrap_or("Library").to_string(), color: parse_color(x.get("color")?)?, opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), + pbr: x.get("pbr").and_then(|p| serde_json::from_value(p.clone()).ok()), }) }) .collect(); @@ -98,6 +101,7 @@ pub struct Ap { pub faces: bool, pub color: [u8; 3], pub opacity: f64, + pub pbr: Option, /// The look picked last (its name), if the colour came from one. pub look: Option, /// Library category shown (0: all). @@ -141,6 +145,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { faces, color: existing.as_ref().map_or([196, 199, 204], |l| l.color), opacity: existing.as_ref().map_or(1.0, |l| l.opacity), + pbr: existing.as_ref().and_then(|l| l.pbr), look: existing.map(|l| l.name), category: 0, search: String::new(), @@ -200,11 +205,15 @@ pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> { } pub(super) fn selected_params(k: &Ap, items: &[Sel]) -> Option { - if let Some(component) = &k.component { + let mut p = if let Some(component) = &k.component { Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look})) } else { apply_params(items, k.color, k.opacity, k.look.as_deref()) + }?; + if let Some(pbr) = k.pbr { + p["pbr"] = json!(pbr); } + Some(p) } /// Removing an override restores the inherited component or material appearance. @@ -214,6 +223,7 @@ fn clear_params(k: &Ap, items: &[Sel]) -> Option { obj.remove("appearance"); obj.remove("color"); obj.remove("opacity"); + obj.remove("pbr"); } Some(p) } @@ -244,6 +254,7 @@ fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> { category: "In This Design".into(), color: parse_color(x.get("color")?)?, opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), + pbr: x.get("pbr").and_then(|p| serde_json::from_value(p.clone()).ok()), }, count(x, "bodies") + count(x, "faces") + count(x, "components"), )) @@ -252,7 +263,7 @@ fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> { } let mut out: Vec<(Look, usize)> = Vec::new(); for l in app.session.doc.appearances.bodies.values() { - let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity }; + let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity, pbr: l.pbr }; match out.iter_mut().find(|(x, _)| *x == look) { Some(e) => e.1 += 1, None => out.push((look, 1)), @@ -347,10 +358,49 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel row_label(ui, "Opacity"); ui.spacing_mut().slider_width = 110.0; // Decimal formatting must not round a saved look merely by drawing its editor. - if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}"))).changed() { + if ui + .add_enabled( + k.pbr.is_none(), + egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}")), + ) + .changed() + { k.remove_override = false; } ui.end_row(); + row_label(ui, app.tr("appearance.surface_response")); + let mut enabled = k.pbr.is_some(); + let toggle = ui.checkbox(&mut enabled, app.tr("appearance.pbr_opaque")).on_hover_text(app.tr("appearance.pbr_help")); + crate::scenario::publish_handle("appearance:pbr", toggle.rect.center()); + if toggle.changed() { + k.pbr = enabled.then(PbrParameters::default); + if enabled { + k.opacity = 1.0; + } + k.remove_override = false; + } + ui.end_row(); + if let Some(parameters) = k.pbr { + let mut metallic = parameters.metallic(); + let mut roughness = parameters.roughness(); + let mut ior = parameters.dielectric_ior(); + for (label, handle, value, range) in [ + ("appearance.metallic", "appearance:metallic", &mut metallic, 0.0..=1.0), + ("appearance.roughness", "appearance:roughness", &mut roughness, 0.045..=1.0), + ("appearance.dielectric_ior", "appearance:ior", &mut ior, 1.0..=3.0), + ] { + row_label(ui, app.tr(label)); + let response = ui.add(egui::DragValue::new(value).range(range).speed(0.01)); + crate::scenario::publish_handle(handle, response.rect.center()); + if response.changed() { + k.remove_override = false; + } + ui.end_row(); + } + if let Ok(parameters) = PbrParameters::new(metallic, roughness, ior) { + k.pbr = Some(parameters); + } + } row_label(ui, ""); let picked: Vec = inputs.first().map(|i| i.items.clone()).unwrap_or_default(); ui.horizontal(|ui| { @@ -396,7 +446,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel egui::Id::new(("ap_used", *i)), look.color, look.opacity, - k.color == look.color && k.opacity == look.opacity, + k.color == look.color && k.opacity == look.opacity && k.pbr == look.pbr, &format!("{} · {n} item(s)", look.name), look.clone(), ) @@ -404,6 +454,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel { k.color = look.color; k.opacity = look.opacity; + k.pbr = look.pbr; k.look = Some(look.name.clone()); k.remove_override = false; } @@ -435,12 +486,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel ui.label(RichText::new(crate::i18n::tr_label("No matching appearances")).color(t.text_dim)); } swatch_rows(ui, &shown, |ui, (i, l)| { - let picked_look = k.look.as_deref() == Some(l.name.as_str()); + let picked_look = k.look.as_deref() == Some(l.name.as_str()) && k.pbr == l.pbr; if swatch(ui, egui::Id::new(("ap_lib", *i)), l.color, l.opacity, picked_look, &format!("{} (drag onto a body or face)", l.name), (*l).clone()) .clicked() { k.color = l.color; k.opacity = l.opacity; + k.pbr = l.pbr; k.look = Some(l.name.clone()); k.remove_override = false; } @@ -452,9 +504,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel let targets = drop_target(app, k.faces); k.color = look.color; k.opacity = look.opacity; + k.pbr = look.pbr; k.look = Some(look.name.clone()); k.remove_override = false; - if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) { + if let Some(mut p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) { + if let Some(pbr) = look.pbr { + p["pbr"] = json!(pbr); + } k.note = Some(match app.run("appearance.assign", p.clone()) { Ok(_) => { k.applied = Some(p); @@ -500,6 +556,32 @@ mod tests { SolveApp::new(s, Services::default()) } + #[test] + fn clicked_pbr_editor_commits_once_and_reopens_without_changing_mass() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.resize", json!({"width":1600,"height":1400})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; + let mass = h.app.session.execute("component.properties", &json!({})).unwrap(); + let before = h.app.session.undo.len(); + h.app.start("appearance.assign"); + h.frames(3); + h.step(&json!({"click":{"handle":"appearance:pbr"}})).unwrap(); + h.step(&json!({"widget":"Apply to selection"})).unwrap(); + assert!(h.app.session.doc.appearances.bodies["Body1"].pbr.is_some()); + assert_eq!(h.app.session.undo.len(), before + 1); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(h.app.session.undo.len(), before + 1); + assert_eq!(h.app.session.execute("component.properties", &json!({})).unwrap(), mass); + h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; + h.app.start("appearance.assign"); + let Kind::Appearance(k) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance editor") }; + assert_eq!(k.pbr, Some(PbrParameters::default())); + assert_eq!(k.opacity, 1.0); + let params = selected_params(k, &h.app.dialog.as_ref().unwrap().inputs[0].items).unwrap(); + assert_eq!(params["pbr"]["roughness"], 0.5); + } + #[test] fn colours_parse_both_ways() { assert_eq!(parse_color(&json!("#0a1B2c")), Some([10, 27, 44])); diff --git a/crates/ui-egui/src/dialogs_library.rs b/crates/ui-egui/src/dialogs_library.rs index 5b9a940b..bdbb154e 100644 --- a/crates/ui-egui/src/dialogs_library.rs +++ b/crates/ui-egui/src/dialogs_library.rs @@ -7,9 +7,12 @@ use serde_json::json; use solidcraft_engine::io::catalog::Catalog; #[cfg(not(target_arch = "wasm32"))] use solidcraft_engine::io::catalog::Definition; +use std::sync::Arc; #[derive(Clone, Debug, Default)] pub struct LocalLibrary { + /// A delayed picker may update only the editor that requested it. + token: Arc<()>, pub search: String, pub path: String, pub catalog: Option, @@ -22,15 +25,55 @@ fn display_order(mut catalog: Catalog) -> Catalog { catalog } -/// Runs `f` on the open Appearance dialog's state, once a file picker has answered. -fn with_state(app: &mut SolveApp, f: impl FnOnce(&mut SolveApp, &mut Ap)) { - let Some(mut dialog) = app.dialog.take() else { return }; - if let crate::dialogs::Kind::Appearance(k) = &mut dialog.kind { - f(app, k); - } - if app.dialog.is_none() { - app.dialog = Some(dialog); - } +fn editor<'a>(app: &'a mut SolveApp, token: &Arc<()>) -> Option<&'a mut Ap> { + let crate::dialogs::Kind::Appearance(k) = &mut app.dialog.as_mut()?.kind else { return None }; + Arc::ptr_eq(&k.local.token, token).then_some(k) +} + +fn save_library(app: &mut SolveApp, token: Arc<()>, overwrite: bool) { + let origin = app.session.doc.clone(); + app.pick_save("my-library.solidcraft-library.json", &["json"], move |app, path| { + let Some(path) = path else { return }; + if !Arc::ptr_eq(&origin, &app.session.doc) { + app.set_status("The library export document changed while choosing its destination", true); + return; + } + match app.run("library.export", json!({"path":path,"overwrite":overwrite})) { + Ok(result) => { + let parsed = result.get("catalog").cloned().and_then(|v| serde_json::from_value::(v).ok()).map(display_order); + let count = parsed.as_ref().map_or(0, |c| c.entries.len()); + if let Some(k) = editor(app, &token) { + k.local.path = path; + k.local.catalog = parsed; + k.local.selected = None; + k.local.search.clear(); + k.note = Some(format!("Saved {count} reusable definitions. The design is unchanged.")); + } + } + Err(error) => { + if let Some(k) = editor(app, &token) { + k.note = Some(error); + } + } + } + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn open_library(app: &mut SolveApp, token: Arc<()>) { + app.pick_open(move |app, path| { + let Some(path) = path else { return }; + let Some(k) = editor(app, &token) else { return }; + let mut local = k.local.clone(); + local.path = path; + local.catalog = None; + local.selected = None; + let note = load(app, &mut local).unwrap_or_else(|error| error); + if let Some(k) = editor(app, &token) { + k.local = local; + k.note = Some(note); + } + }); } /// Exports the design's library to `path` and shows it as the local library. @@ -74,17 +117,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInpu let save = ui.button(crate::i18n::tr_label("Export design library...")); crate::scenario::publish_handle("library:export", save.rect.center()); if save.clicked() { - let overwrite = k.local.overwrite; if app.services.pick_save.is_some() { - // The picker answers in a later frame (#1088); the export runs then. - app.pick_save("my-library.solidcraft-library.json", &["json"], move |app, path| { - if let Some(path) = path { - with_state(app, |app, k| export(app, k, path, overwrite)); - } - }); + save_library(app, k.local.token.clone(), k.local.overwrite); } else if !k.local.path.is_empty() { - let path = k.local.path.clone(); - export(app, k, path, overwrite); + export(app, k, k.local.path.clone(), k.local.overwrite); } } ui.label( @@ -106,17 +142,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI } ui.horizontal(|ui| { if ui.button(crate::i18n::tr_label("Open library...")).clicked() { - // The picker answers in a later frame (#1088); the library loads then. - app.pick_open(|app, path| { - if let Some(path) = path { - with_state(app, |app, k| { - k.local.path = path; - k.local.catalog = None; - k.local.selected = None; - k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error)); - }); - } - }); + open_library(app, k.local.token.clone()); } let load_button = ui.add_enabled(!k.local.path.is_empty(), egui::Button::new(crate::i18n::tr_label("Load library"))); crate::scenario::publish_handle("library:load", load_button.rect.center()); @@ -194,6 +220,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI Ok(_) => { if let Definition::Appearance(look) = entry.definition { k.color = look.color; + k.pbr = None; k.opacity = look.opacity; k.look = Some(look.name.clone()); k.remove_override = false; diff --git a/crates/ui-egui/src/dialogs_library_tests.rs b/crates/ui-egui/src/dialogs_library_tests.rs index ad299947..ea82b6b2 100644 --- a/crates/ui-egui/src/dialogs_library_tests.rs +++ b/crates/ui-egui/src/dialogs_library_tests.rs @@ -12,7 +12,13 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() { source.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap(); source.execute("library.export", &json!({"path":path})).unwrap(); h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); - h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[20,20,20]})).unwrap(); + h.app + .run( + "appearance.assign", + json!({"bodies":["Body1"],"color":[20,20,20], + "pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}), + ) + .unwrap(); h.app.session.mark_saved(); h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; h.app.start("appearance.assign"); @@ -27,6 +33,7 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() { h.step(&json!({"click":{"handle":"library:entry:0"}})).unwrap(); h.step(&json!({"widget":"Apply library appearance"})).unwrap(); assert_eq!(h.app.session.doc.appearances.bodies["Body1"].name, "Warm oak colour"); + assert!(h.app.session.doc.appearances.bodies["Body1"].pbr.is_none()); assert_eq!(h.app.session.undo.len(), undo + 1); let revision = h.app.session.revision; h.step(&json!({"widget":"Export design library..."})).unwrap(); @@ -39,3 +46,121 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() { assert!(h.app.dialog.is_none()); assert_eq!(h.app.session.undo.len(), undo + 1); } + +fn appearance_fixture() -> Result<(Harness, Arc<()>), String> { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10}))?; + h.app.run("appearance.assign", json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70]}))?; + h.app.session.mark_saved(); + let token = start_appearance(&mut h)?; + Ok((h, token)) +} + +fn start_appearance(h: &mut Harness) -> Result, String> { + h.app.start("appearance.assign"); + match h.app.dialog.as_ref().map(|dialog| &dialog.kind) { + Some(Kind::Appearance(k)) => Ok(k.local.token.clone()), + _ => Err("appearance dialog missing".into()), + } +} + +#[test] +fn async_library_export_holds_its_document_until_the_answer() { + use crate::picker_test_support::{Slot, save}; + let (mut h, token) = appearance_fixture().unwrap(); + let original = h.app.session.doc.clone(); + let path = h.dir.join("delayed-library.json"); + let ctx = egui::Context::default(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + save_library(&mut h.app, token, false); + crate::file_picker::tick(&mut h.app, &ctx); + assert!(!path.exists()); + crate::documents::new_design(&mut h.app); + let other = h.app.session.doc.clone(); + *slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned())); + crate::file_picker::tick(&mut h.app, &ctx); + assert_eq!(h.app.docs.active, 0, "upstream holds tab changes while a picker is pending"); + assert!(Arc::ptr_eq(&other, &h.app.session.doc)); + let catalog = Catalog::read(&path.to_string_lossy()).unwrap(); + assert_eq!(catalog.entries.len(), 1); + assert_eq!(catalog.entries[0].definition.name(), "Warm oak colour"); + crate::documents::switch(&mut h.app, 0); + assert!(Arc::ptr_eq(&original, &h.app.session.doc)); + assert!(!h.app.session.is_dirty()); +} + +#[test] +fn async_library_export_captures_overwrite_consent_and_cancel_writes_nothing() { + use crate::picker_test_support::{Slot, save}; + let (mut h, token) = appearance_fixture().unwrap(); + let path = h.dir.join("existing-library.json"); + std::fs::write(&path, b"keep this file").unwrap(); + let original = h.app.session.doc.clone(); + let ctx = egui::Context::default(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + save_library(&mut h.app, token.clone(), false); + crate::file_picker::tick(&mut h.app, &ctx); + editor(&mut h.app, &token).unwrap().local.overwrite = true; + *slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned())); + crate::file_picker::tick(&mut h.app, &ctx); + assert_eq!(std::fs::read(&path).unwrap(), b"keep this file"); + assert!(matches!(&h.app.status, Some((_, _, true)))); + save_library(&mut h.app, token, false); + crate::file_picker::tick(&mut h.app, &ctx); + *slot.borrow_mut() = Some(None); + crate::file_picker::tick(&mut h.app, &ctx); + assert!(!h.app.file_picker_busy()); + assert_eq!(std::fs::read(&path).unwrap(), b"keep this file"); + assert!(Arc::ptr_eq(&original, &h.app.session.doc)); +} + +#[test] +fn async_library_open_does_not_replace_a_new_appearance_editor() { + use crate::picker_test_support::{Slot, save}; + let (mut h, token) = appearance_fixture().unwrap(); + let path = h.dir.join("source-library.json"); + h.app.run("library.export", json!({"path":path})).unwrap(); + let original = h.app.session.doc.clone(); + let ctx = egui::Context::default(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + // Prepare a different editor before the picker holds user commands, then model + // an internal host replacing it while the old asynchronous answer is pending. + let original_editor = h.app.dialog.take(); + let newer = start_appearance(&mut h).unwrap(); + editor(&mut h.app, &newer).unwrap().local.path = "new editor draft".into(); + let replacement_editor = h.app.dialog.take(); + h.app.dialog = original_editor; + crate::picker_test_support::open(&mut h.app, &slot); + open_library(&mut h.app, token); + crate::file_picker::tick(&mut h.app, &ctx); + h.app.dialog = replacement_editor; + *slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned())); + crate::file_picker::tick(&mut h.app, &ctx); + let k = editor(&mut h.app, &newer).unwrap(); + assert_eq!(k.local.path, "new editor draft"); + assert!(k.local.catalog.is_none()); + assert!(Arc::ptr_eq(&original, &h.app.session.doc)); +} + +#[test] +fn delayed_library_export_refuses_an_internally_changed_design_without_overwriting() { + use crate::picker_test_support::{Slot, save}; + let (mut h, token) = appearance_fixture().unwrap(); + let path = h.dir.join("keep.json"); + std::fs::write(&path, b"keep library").unwrap(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + save_library(&mut h.app, token, true); + h.app.session.execute("appearance.assign", &json!({"bodies":["Body1"],"color":[1,2,3]})).unwrap(); + let changed = h.app.session.doc.clone(); + let history = (h.app.session.undo.len(), h.app.session.revision); + *slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned())); + crate::file_picker::tick(&mut h.app, &egui::Context::default()); + assert_eq!(std::fs::read(path).unwrap(), b"keep library"); + assert!(Arc::ptr_eq(&changed, &h.app.session.doc)); + assert_eq!((h.app.session.undo.len(), h.app.session.revision), history); + assert!(matches!(&h.app.status, Some((_, _, true)))); +} diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index be88afd6..0289c3a1 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -27,6 +27,7 @@ pub struct DocSlot { /// The per-design part of the UI state (what is hidden, shown or locked). #[derive(Clone, Debug, Default)] struct DocView { + render: crate::render_workspace::ViewState, sketch_choices: crate::sketch_families::Choices, hidden_profiles: Vec, shown_dims: Vec, @@ -64,6 +65,7 @@ impl Documents { fn take_view(app: &mut SolveApp) -> DocView { let u = &mut app.ui; DocView { + render: std::mem::take(&mut app.render_workspace.view), sketch_choices: std::mem::take(&mut app.sketch_choices), hidden_profiles: std::mem::take(&mut u.hidden_profiles), shown_dims: std::mem::take(&mut u.shown_dims), @@ -72,6 +74,9 @@ fn take_view(app: &mut SolveApp) -> DocView { } fn put_view(app: &mut SolveApp, v: DocView) { + app.render_workspace.view = v.render; + crate::render_workspace::invalidate_scene(app); + crate::render_workspace::sync_backend(app); app.sketch_choices = v.sketch_choices; let u = &mut app.ui; u.hidden_profiles = v.hidden_profiles; @@ -81,6 +86,9 @@ fn put_view(app: &mut SolveApp, v: DocView) { /// Put `slot` into the app and return what was there. fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { + if crate::render_workspace::active(app) { + let _ = crate::render_workspace::enter(app, false); + } // Whatever was going on in the design leaves with it. crate::sketch_text_move::cancel_drag(app); crate::dialogs::cancel(app); @@ -162,6 +170,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) { app.menu.props = None; crate::sketch_attachment_ui::clear(app); app.session.replace_design(session); + crate::render_workspace::reset_document(app); app.sketch_choices = crate::sketch_families::Choices::default(); app.session.invalidate_view(rev); app.cam = Camera::default(); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index d35e905c..2ea1ce3a 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -11,6 +11,9 @@ use egui_wgpu::wgpu::util::DeviceExt; /// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8). pub const TRI_SIZE: usize = 28; +#[path = "pbr_gpu.rs"] +mod pbr; +pub const PBR_SIZE: usize = pbr::VERTEX_SIZE; /// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32). pub const LINE_SIZE: usize = 32; /// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their @@ -31,6 +34,8 @@ pub struct PlannedBody { pub struct GpuScene { /// Opaque triangles. A zero normal draws the colour flat (unlit). pub tris: Vec, + /// Opaque product PBR vertices; linear base color and explicit optical parameters. + pub pbr: Vec, /// Translucent triangles (alpha from the colour), drawn after the opaque ones without /// writing depth. pub trans: Vec, @@ -99,6 +104,13 @@ pub struct BlockBytes { } impl GpuScene { + pub fn pbr_tri(&mut self, p: [f32; 3], n: [f32; 3], surface: solidcraft_engine::render::surface::OpaqueSurface) { + let base = surface.base_color().to_f32(); + let parameters = [surface.metallic() as f32, surface.roughness() as f32, surface.dielectric_ior() as f32]; + for value in p.into_iter().chain(n).chain(base).chain(parameters) { + self.pbr.extend_from_slice(&value.to_le_bytes()); + } + } pub fn tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { for v in p.iter().chain(&n) { self.tris.extend_from_slice(&v.to_le_bytes()); @@ -176,6 +188,8 @@ const IMG_SIZE: usize = 24; /// Per-frame parameters. pub struct ViewportCallback { + /// Product preview uses its own batches, retaining the Design viewport's uploads. + pub product: bool, pub key: u64, pub slot: SceneSlot, pub sketch_key: u64, @@ -265,6 +279,7 @@ struct Batches { blocks: std::collections::HashMap, key: Option, tris: Option, + pbr: Option, trans: Option, glass: Option, /// The glass triangles as built, and the view direction they were last sorted for. @@ -315,6 +330,7 @@ impl Batches { } } self.tris = joined(blocks.iter().map(|b| &b.tris), upload(device, "sc_tris", &sc.tris, TRI_SIZE)); + self.pbr = upload(device, "sc_pbr", &sc.pbr, PBR_SIZE); self.trans = joined(blocks.iter().map(|b| &b.trans), upload(device, "sc_trans", &sc.trans, TRI_SIZE)); self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST); self.glass_cpu = sc.glass; @@ -401,6 +417,7 @@ struct Resources { tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, /// Highlight triangles over the model (depth test ≤, no depth write). tri_hl: wgpu::RenderPipeline, + pbr: wgpu::RenderPipeline, trans: wgpu::RenderPipeline, /// Glass: far sides (front faces culled), then near sides (back faces culled). glass_far: wgpu::RenderPipeline, @@ -415,6 +432,8 @@ struct Resources { bind: wgpu::BindGroup, linear_out: bool, model: Batches, + product: Batches, + empty: Batches, sketch: Batches, highlight: Batches, preview: Batches, @@ -471,6 +490,11 @@ fn tri_layout() -> wgpu::VertexBufferLayout<'static> { /// A planned body's vertices as the GPU evaluation writes them: the position, then the normal /// at [`solidcraft_gpu::NORMAL_OFFSET`] (each padded to four floats). +fn pbr_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3, 3 => Float32x3]; + wgpu::VertexBufferLayout { array_stride: PBR_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } +} + fn planned_layout() -> wgpu::VertexBufferLayout<'static> { const A: [wgpu::VertexAttribute; 2] = [ wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 }, @@ -502,8 +526,9 @@ pub struct GpuTarget { impl Resources { fn new(device: &wgpu::Device, t: GpuTarget, tess: Option) -> Self { + let source = format!("{SHADER}\n{}", pbr::SOURCE); let module = - device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(source.into()) }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("sc_viewport"), entries: &[wgpu::BindGroupLayoutEntry { @@ -730,6 +755,7 @@ impl Resources { access_bind, access_version: 0, tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha), + pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), depth(wgpu::CompareFunction::Less, true), None), trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), glass_far: glass("sc_glass_far", wgpu::Face::Front), glass_near: glass("sc_glass_near", wgpu::Face::Back), @@ -742,6 +768,8 @@ impl Resources { bind, linear_out: t.format.is_srgb(), model: Batches::default(), + product: Batches::default(), + empty: Batches::default(), sketch: Batches::default(), highlight: Batches::default(), preview: Batches::default(), @@ -875,6 +903,12 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { resources: &mut egui_wgpu::CallbackResources, ) -> Vec { let Some(res) = resources.get_mut::() else { return Vec::new() }; + if self.product { + res.product.take(device, self.key, &self.slot, None); + res.product.sort_glass(queue, self.back); + queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); + return Vec::new(); + } res.model.take(device, self.key, &self.slot, res.tess.as_ref().map(|t| (t, &mut *encoder))); res.sketch.take(device, self.sketch_key, &self.sketch_slot, None); res.highlight.take(device, self.hl_key, &self.hl_slot, None); @@ -965,7 +999,8 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { } } }; - let (m, sk, h, pv) = (&res.model, &res.sketch, &res.highlight, &res.preview); + let (m, sk, h, pv) = + if self.product { (&res.product, &res.empty, &res.empty, &res.empty) } else { (&res.model, &res.sketch, &res.highlight, &res.preview) }; lines(pass, &res.overlay, &m.under); lines(pass, &res.overlay, &sk.under); pass.set_bind_group(1, &res.access_bind, &[]); @@ -995,6 +1030,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { planned(pass, &res.tri_planned); } } + tris(pass, &res.pbr, &m.pbr); tris(pass, &res.tri_hl, &h.tris); lines(pass, &res.line, &m.lines); lines(pass, &res.line, &sk.lines); @@ -1002,7 +1038,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { lines(pass, &res.line_hidden, &h.hidden); lines(pass, &res.line, &pv.lines); lines(pass, &res.line, &h.lines); - for (id, q) in &res.quads { + for (id, q) in res.quads.iter().filter(|_| !self.product) { if let Some((_, bind)) = res.textures.get(id) { pass.set_pipeline(&res.image); pass.set_bind_group(1, bind, &[]); @@ -1066,14 +1102,52 @@ pub(crate) fn render_offscreen( analysis: [f32; 8], colors: [f32; 36], ) -> Option> { + offscreen(scene, highlight, view_proj, back, eye, size, Offscreen { tess, product: false, srgb: false, analysis, analysis_colors: colors }) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn render_product_offscreen( + scene: GpuScene, + view_proj: [[f32; 4]; 4], + back: [f32; 3], + eye: [f32; 4], + size: u32, + srgb: bool, +) -> Option> { + offscreen(scene, None, view_proj, back, eye, size, Offscreen { tess: None, product: true, srgb, analysis: [0.0; 8], analysis_colors: [0.0; 36] }) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +struct Offscreen<'a> { + tess: Option<&'a str>, + product: bool, + srgb: bool, + analysis: [f32; 8], + analysis_colors: [f32; 36], +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +fn offscreen( + scene: GpuScene, + highlight: Option, + view_proj: [[f32; 4]; 4], + back: [f32; 3], + eye: [f32; 4], + size: u32, + options: Offscreen<'_>, +) -> Option> { + let Offscreen { tess, product, srgb, analysis, analysis_colors } = options; let (adapter, device, queue) = offscreen_device()?; - let (format, depth) = (wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Depth24PlusStencil8); + eprintln!("offscreen adapter: {:?}", adapter.get_info().backend); + let format = if srgb { wgpu::TextureFormat::Rgba8UnormSrgb } else { wgpu::TextureFormat::Rgba8Unorm }; + let depth = wgpu::TextureFormat::Depth24PlusStencil8; let compute = tess.and_then(|shader| tessellation(&adapter, &device, &queue, shader)); let target = GpuTarget { format, depth: Some(depth), samples: 1, tess: compute.is_some() }; let mut resources = egui_wgpu::CallbackResources::default(); resources.insert(Resources::new(&device, target, compute)); let none = || Arc::new(Mutex::new(None)); let cb = ViewportCallback { + product, key: 1, slot: Arc::new(Mutex::new(Some(scene))), sketch_key: 0, @@ -1089,7 +1163,7 @@ pub(crate) fn render_offscreen( cap: [0.0; 4], images: Vec::new(), analysis, - analysis_colors: colors, + analysis_colors, access: None, outline: false, eye, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d9f90397..95eeee85 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,7 @@ pub mod preview; mod primitive_units_tests; pub mod recovery_ui; pub mod ref_images; +pub mod render_workspace; #[cfg(test)] mod revolve_edit_tests; #[cfg(test)] @@ -217,7 +218,7 @@ impl Default for UiState { } } -/// Host services (file pickers) injected by the app so this crate stays portable. +/// Host services injected by the app so this crate stays portable. #[derive(Default)] pub struct Services { pub pick_open: Option FilePickerFuture>>, @@ -247,6 +248,7 @@ pub struct SolveApp { pub services: Services, file_picker: Option, pub viewport: viewport::ViewportState, + pub render_workspace: render_workspace::State, pub tool: Option, pub dialog: Option, /// The Change Parameters window, when open. @@ -331,7 +333,16 @@ pub struct SolveApp { } impl SolveApp { - pub fn new(session: Session, services: Services) -> Self { + pub fn new(mut session: Session, services: Services) -> Self { + #[cfg(test)] + let image_host = Some(solidcraft_engine::render::job::CpuHost::cooperative()); + #[cfg(not(test))] + let image_host = solidcraft_engine::render::job::CpuHost::for_platform().map_err(|e| log::error!("render image host: {e}")).ok(); + if let Err(e) = session.set_render_job_host(image_host.clone()) { + log::error!("render image host: {e}"); + } + let mut render_workspace = render_workspace::State::default(); + render_workspace.image_host = image_host; SolveApp { session, ui: UiState::default(), @@ -343,6 +354,7 @@ impl SolveApp { services, file_picker: None, viewport: viewport::ViewportState::default(), + render_workspace, tool: None, dialog: None, params: None, @@ -408,6 +420,19 @@ impl SolveApp { /// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU. pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) { self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples, !self.cpu_meshing)); + render_workspace::invalidate_scene(self); + self.render_workspace.host = match render_workspace::host_executor( + rs.device.limits().max_texture_dimension_2d, + self.viewport.gpu.is_some_and(|g| g.depth.is_some()), + samples.max(1), + ) { + Ok(executor) => Some(executor), + Err(error) => { + self.set_status(error, true); + None + } + }; + render_workspace::sync_backend(self); self.msaa_supported = rs.msaa_supported.clone(); let info = rs.adapter.get_info(); self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)); @@ -541,6 +566,7 @@ impl SolveApp { let label = language.label(command_label(id)); self.session.echo(language.format("ui.command_done", &[("command", label)])); if matches!(id, "file.new" | "doc.open" | "doc.recover") { + render_workspace::reset_document(self); self.sketch_choices = sketch_families::Choices::default(); dim_view::cancel(); self.menu.props = None; @@ -882,6 +908,15 @@ impl SolveApp { } pub fn fit_view(&mut self) { + if render_workspace::active(self) { + self.cancel_view_animation(); + match solidcraft_engine::product_scene::extract(&self.session) { + Ok(scene) if !scene.bounds.is_empty() => self.cam.fit(&scene.bounds), + Err(e) => self.set_status(e.to_string(), true), + _ => {} + } + return; + } self.cam_anim = None; self.viewport.orbit_snap = None; self.viewport.orbit_free = None; @@ -960,6 +995,13 @@ impl SolveApp { /// Animated view changes for the axis view and navigation bar. pub fn animate_view(&mut self, v: &str) { + if render_workspace::active(self) && matches!(v, "fit" | "home") { + if v == "home" { + self.cam.set_view(StandardView::Iso); + } + self.fit_view(); + return; + } self.pending_fit = None; let mut to = self.cam; match v { @@ -993,7 +1035,11 @@ impl SolveApp { /// change, or at once (control requests, so a screenshot right after shows it). pub fn follow_view_request(&mut self, animate: bool) { let Some(mut to) = self.session.camera.take() else { return }; - to.up = self.cam.up; + if render_workspace::active(self) { + self.ui.up_axis = to.up; + } else { + to.up = self.cam.up; + } if animate { self.animate_to(to); } else if to.is_valid() { @@ -1033,6 +1079,8 @@ impl SolveApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { file_picker::tick(self, ctx); + render_workspace::sync_backend(self); + render_workspace::tick(self, ctx); file_load::tick(self, ctx); self.esc_handled = false; prefs::apply(self, ctx); @@ -1143,8 +1191,9 @@ impl SolveApp { } } toolbar::toolbar(self, ui); - let left_timeline = self.ui.show_timeline && self.preferences.timeline_left; - if self.ui.show_timeline && !left_timeline { + let show_timeline = self.ui.show_timeline && !render_workspace::active(self); + let left_timeline = show_timeline && self.preferences.timeline_left; + if show_timeline && !left_timeline { timeline::timeline(self, ui); } palette::palette_bar(self, ui); @@ -1157,9 +1206,16 @@ impl SolveApp { } else if left_timeline { timeline_left::left_panel(self, ui); } + if render_workspace::active(self) { + render_workspace::panel(self, ui); + } preview::update(self, ui.ctx()); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { - viewport::show(self, ui); + if render_workspace::active(self) { + render_workspace::show(self, ui); + } else { + viewport::show(self, ui); + } }); dialogs::show(self, ui.ctx()); issue_report::CadInput::block(self, ui, report_input); @@ -1303,3 +1359,6 @@ pub fn now_ms() -> f64 { use web_time::{SystemTime, UNIX_EPOCH}; SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) } + +#[cfg(test)] +mod picker_test_support; diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index f9449df4..e83cc047 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -660,11 +660,12 @@ fn toolbar_pins_removes_reorders_and_keeps_its_layout() { } #[test] -fn the_workspace_menu_lists_design_and_greys_the_rest() { +fn the_workspace_menu_lists_design_and_render_and_greys_future_workspaces() { let app = sample_app(); let items = context_menu::items(&app, &Target::Workspace); assert!(find(&items, "Design").enabled); - assert!(!find(&items, "Render").enabled && find(&items, "Render").shortcut == "coming later"); + assert!(find(&items, "Render").enabled); + assert!(!find(&items, "Animation").enabled); assert!(!items.iter().any(|i| i.label.contains("Generative"))); } diff --git a/crates/ui-egui/src/pbr_gpu.rs b/crates/ui-egui/src/pbr_gpu.rs new file mode 100644 index 00000000..bd3618d2 --- /dev/null +++ b/crates/ui-egui/src/pbr_gpu.rs @@ -0,0 +1,78 @@ +//! Original GPU translation of the L1 opaque surface and fixed studio reference. +//! Appended to the existing viewport shader so camera/output conventions are shared. + +pub const VERTEX_SIZE: usize = 48; + +pub const SOURCE: &str = r#" +struct PbrOut { + @builtin(position) pos: vec4, + @location(0) n: vec3, + @location(1) base: vec3, + @location(2) wp: vec3, + @location(3) parameters: vec3, +}; + +fn pbr_unit(v: vec3) -> vec3 { + let scale = max(max(abs(v.x), abs(v.y)), abs(v.z)); + if (scale == 0.0) { return vec3(0.0, 0.0, 1.0); } + let s = v / scale; + return s / length(s); +} + +fn pbr_encode(c: vec3) -> vec3 { + return select(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - vec3(0.055), c > vec3(0.0031308)); +} + +@vertex +fn vs_pbr(@location(0) p: vec3, @location(1) n: vec3, @location(2) base: vec3, @location(3) parameters: vec3) -> PbrOut { + var o: PbrOut; + o.pos = u.vp * vec4(p, 1.0); + o.n = n; + o.base = base; + o.wp = p; + o.parameters = parameters; + return o; +} + +fn pbr_fresnel(f0: vec3, cosine: f32) -> vec3 { + let t = 1.0 - cosine; + return f0 + (vec3(1.0) - f0) * (t * t * t * t * t); +} + +fn pbr_brdf(base: vec3, parameters: vec3, n: vec3, v: vec3, l: vec3) -> vec3 { + let nv = clamp(dot(n, v), -1.0, 1.0); + let nl = clamp(dot(n, l), -1.0, 1.0); + if (nv <= 0.0 || nl <= 0.0) { return vec3(0.0); } + let h = pbr_unit(v + l); + let nh = clamp(dot(n, h), 0.0, 1.0); + let vh = clamp(dot(v, h), 0.0, 1.0); + let alpha = parameters.y * parameters.y; + let a2 = alpha * alpha; + let denominator = (1.0 - nh * nh) + nh * nh * a2; + let distribution = a2 / (3.141592653589793 * denominator * denominator); + let gv = 2.0 / (nv + sqrt(a2 + (1.0 - a2) * nv * nv)); + let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl)); + let ratio = (parameters.z - 1.0) / (parameters.z + 1.0); + let f0 = mix(vec3(ratio * ratio), base, parameters.x); + let specular = pbr_fresnel(f0, vh) * (distribution * 0.25 * gv * gl); + let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793); + return specular + (vec3(1.0) - pbr_fresnel(f0, nv)) * (vec3(1.0) - pbr_fresnel(f0, nl)) * diffuse; +} + +@fragment +fn fs_pbr(i: PbrOut) -> @location(0) vec4 { + let n = pbr_unit(i.n); + var view = u.back.xyz; + if (u.eye.w > 0.5) { view = u.eye.xyz - i.wp; } + let v = pbr_unit(view); + let lights = array, 3>(vec4(0.6, 0.0, 0.8, 3.0), vec4(-0.8, 0.0, 0.6, 1.0), vec4(0.0, -0.6, 0.8, 1.5)); + var radiance = vec3(0.0); + for (var k = 0u; k < 3u; k += 1u) { + let light = lights[k]; + radiance += pbr_brdf(i.base, i.parameters, n, v, light.xyz) * (light.w * max(dot(n, light.xyz), 0.0)); + } + let mapped = radiance / (vec3(1.0) + radiance); + if (u.back.w > 0.5) { return vec4(mapped, 1.0); } + return vec4(pbr_encode(mapped), 1.0); +} +"#; diff --git a/crates/ui-egui/src/picker_test_support.rs b/crates/ui-egui/src/picker_test_support.rs new file mode 100644 index 00000000..efd53678 --- /dev/null +++ b/crates/ui-egui/src/picker_test_support.rs @@ -0,0 +1,25 @@ +//! Controlled host futures shared by Render and library picker regressions. +use crate::{SolveApp, file_picker::FilePickerFuture}; +use std::{ + cell::RefCell, + future::Future, + pin::Pin, + rc::Rc, + task::{Context, Poll}, +}; +pub type Slot = Rc>>>; +struct Manual(Slot); +impl Future for Manual { + type Output = Option; + fn poll(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll { + self.0.borrow_mut().take().map_or(Poll::Pending, Poll::Ready) + } +} +pub fn save(app: &mut SolveApp, slot: &Slot) { + let slot = slot.clone(); + app.services.pick_save = Some(Box::new(move |_, _| Box::pin(Manual(slot.clone())) as FilePickerFuture)); +} +pub fn open(app: &mut SolveApp, slot: &Slot) { + let slot = slot.clone(); + app.services.pick_open = Some(Box::new(move || Box::pin(Manual(slot.clone())) as FilePickerFuture)); +} diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs new file mode 100644 index 00000000..690a5c87 --- /dev/null +++ b/crates/ui-egui/src/render_workspace.rs @@ -0,0 +1,535 @@ +//! Integrated product preview. Shares the installed device/pipelines, not Design's scene policy. + +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +use egui::{Color32, Sense}; +use serde_json::json; +use solidcraft_engine::doc::render_settings::RenderSetup; +use solidcraft_engine::render::{ + Availability, Camera, ExecutorDescriptor, ExecutorId, ProductScene, RenderLimits, RenderMode, RenderOperation, RenderProfile, RenderRequest, + ScratchLayout, +}; + +use crate::gpu::{GpuScene, SceneSlot, ViewportCallback}; +use crate::{SolveApp, theme::Tokens}; + +const MAX_GEOMETRY_BYTES: usize = 128 * 1024 * 1024; + +#[cfg(test)] +#[path = "render_workspace_tests.rs"] +mod tests; + +pub struct State { + pub host: Option, + pub image_host: Option, + pub view: ViewState, + revision: u64, + design_camera: Option<(Camera, bool)>, + slot: SceneSlot, + key: Option, + scene_signature: Option<(u64, usize, u64)>, + scene: Option, +} + +/// Small per-document editor state; GPU uploads and host capabilities stay outside tabs. +#[derive(Clone, Debug)] +pub struct ViewState { + pub name: String, + draft: RenderSetup, + dirty: bool, + job: Option, + completed: Option, + transparent: bool, + overwrite: bool, +} + +impl Default for ViewState { + fn default() -> Self { + Self { name: "Studio".into(), draft: RenderSetup::default(), dirty: false, job: None, completed: None, transparent: false, overwrite: false } + } +} + +impl Default for State { + fn default() -> Self { + Self { + host: None, + image_host: None, + view: Default::default(), + revision: u64::MAX, + design_camera: None, + slot: Arc::new(Mutex::new(None)), + key: None, + scene_signature: None, + scene: None, + } + } +} + +pub fn active(app: &SolveApp) -> bool { + app.ui.tab == "RENDER" +} + +pub fn host_executor(max_dimension: u32, depth: bool, samples: u32) -> Result { + if !matches!(samples, 1 | 2 | 4 | 8 | 16) { + return Err("unsupported GPU sample count".into()); + } + Ok(ExecutorDescriptor { + id: ExecutorId::new("gpu-viewport").map_err(|e| e.to_string())?, + availability: if depth && max_dimension > 0 { + Availability::Available + } else { + Availability::Unavailable { reason: "product preview requires an installed device with a depth buffer".into() } + }, + profiles: vec![RenderProfile { + mode: RenderMode::Preview, + operation: RenderOperation::Viewport, + formats: Vec::new(), + features: vec![solidcraft_engine::render::RenderFeature::Pbr], + }], + limits: RenderLimits { max_dimension: u64::from(max_dimension.clamp(1, 4096)), ..Default::default() }, + scratch: ScratchLayout { + sample_scale: 1, + sample_bytes_per_pixel: 8 * u64::from(samples), + output_bytes_per_pixel: 8, + encoding_bytes_per_pixel: 0, + }, + }) +} + +pub fn sync_backend(app: &mut SolveApp) { + if app.session.render_job_host().is_none() + && let Some(host) = &app.render_workspace.image_host + && let Err(e) = app.session.set_render_job_host(Some(host.clone())) + { + app.set_status(e.to_string(), true); + } + if app.session.render_viewport() == app.render_workspace.host.as_ref() { + return; + } + if let Err(e) = app.session.set_render_viewport(app.render_workspace.host.clone()) { + app.set_status(e.to_string(), true); + } +} + +/// Cooperative work belongs to the frame loop, never to a status/control request. Keep +/// scheduling frames even after leaving Render so a browser job can finish in Design. +pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { + if let Some(host) = &app.render_workspace.image_host { + match host.pump(1024) { + Ok(true) => ctx.request_repaint(), + Ok(false) => {} + Err(e) => app.set_status(e.to_string(), true), + } + } +} + +pub fn reset_document(app: &mut SolveApp) { + let host = app.render_workspace.host.clone(); + let image_host = app.render_workspace.image_host.clone(); + app.render_workspace = State { host, image_host, ..Default::default() }; + if active(app) { + app.ui.tab = "SOLID".into(); + } + sync_backend(app); +} + +pub fn invalidate_scene(app: &mut SolveApp) { + app.render_workspace.revision = u64::MAX; + app.render_workspace.scene_signature = None; + app.render_workspace.scene = None; + app.render_workspace.key = None; +} + +pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> { + if render { + if app.session.active_sketch.is_some() || app.tool.is_some() || app.dialog.is_some() || app.file_load.busy() { + return Err("finish the current modelling operation before entering Render".into()); + } + app.run("render.scene", json!({}))?; + if !active(app) { + app.render_workspace.design_camera = Some((app.cam, app.ui.perspective)); + app.ui.tab = "RENDER".into(); + let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?; + if !scene.bounds.is_empty() { + app.cam.fit(&scene.bounds); + } + } + } else { + if let Some((camera, perspective)) = app.render_workspace.design_camera.take() { + app.cam = camera; + app.ui.up_axis = camera.up; + app.ui.perspective = perspective; + } + app.ui.tab = "SOLID".into(); + } + app.cancel_view_animation(); + Ok(()) +} + +pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) { + if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty { + app.render_workspace.view.draft = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default(); + app.render_workspace.revision = app.session.revision; + } + egui::Panel::right("sc_render_settings") + .exact_size(285.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(Tokens::get().panel).inner_margin(10)) + .show(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.heading(app.tr("cmd.render_settings_get")); + ui.label(app.tr("render.preview")); + ui.small(app.tr("render.opaque_preview_help")); + ui.separator(); + let names: Vec<_> = app.session.doc.render.setups.keys().cloned().collect(); + let old_name = app.render_workspace.view.name.clone(); + egui::ComboBox::from_id_salt("render_setups").selected_text(&old_name).show_ui(ui, |ui| { + for name in names { + ui.selectable_value(&mut app.render_workspace.view.name, name.clone(), name); + } + }); + ui.label(app.tr("render.setup_name")); + ui.add(egui::TextEdit::singleline(&mut app.render_workspace.view.name).char_limit(128)); + if app.render_workspace.view.name != old_name { + app.render_workspace.view.draft = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default(); + app.render_workspace.view.dirty = false; + } + ui.separator(); + ui.label(app.tr("render.background")); + if ui.color_edit_button_srgb(&mut app.render_workspace.view.draft.background).changed() { + app.render_workspace.view.dirty = true; + } + let mut perspective = app.cam.fov > 0.0; + if ui.checkbox(&mut perspective, app.tr("render.perspective")).changed() { + app.cam.fov = if perspective { std::f64::consts::FRAC_PI_4 } else { 0.0 }; + app.render_workspace.view.dirty = true; + } + if ui.button(app.tr("render.fit")).clicked() { + match solidcraft_engine::product_scene::extract(&app.session) { + Ok(scene) if !scene.bounds.is_empty() => app.cam.fit(&scene.bounds), + Err(e) => app.set_status(e.to_string(), true), + _ => {} + } + } + if ui.button(app.tr("render.restore_camera")).clicked() { + let name = app.render_workspace.view.name.clone(); + if app.run("render.settings.apply", json!({"name":name})).is_ok() { + app.follow_view_request(false); + } + } + if ui.button(app.tr("render.save_setup")).clicked() { + let name = app.render_workspace.view.name.clone(); + let setup = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() }; + if app.run("render.settings.set", json!({"name":name,"set":setup})).is_ok() { + app.render_workspace.view.dirty = false; + } + } + if app.render_workspace.view.dirty { + ui.label(app.tr("render.unsaved")); + } + if ui.button(app.tr("render.reload_settings")).clicked() { + app.render_workspace.view.dirty = false; + app.render_workspace.revision = u64::MAX; + } + if app.render_workspace.view.draft.backend.as_str() != "builtin" || app.render_workspace.view.draft.mode != RenderMode::Preview { + ui.label(app.tr("render.unavailable_preview")); + if ui.button(app.tr("render.use_preview")).clicked() { + app.render_workspace.view.draft.backend = solidcraft_engine::render::builtin_backend().id; + app.render_workspace.view.draft.mode = RenderMode::Preview; + app.render_workspace.view.dirty = true; + } + } + ui.separator(); + image_controls(app, ui); + ui.separator(); + ui.small(app.tr("render.navigation_help")); + if ui.button(app.tr("render.return_design")).clicked() + && let Err(e) = enter(app, false) + { + app.set_status(e, true); + } + }); + }); +} + +fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) { + ui.heading(app.tr("render.image_title")); + ui.small(app.tr("render.opaque_image_summary")); + ui.horizontal(|ui| { + ui.label(app.tr("render.image_width")); + if ui.add(egui::DragValue::new(&mut app.render_workspace.view.draft.width).range(1..=4096)).changed() { + app.render_workspace.view.dirty = true; + } + ui.label(app.tr("render.image_height")); + if ui.add(egui::DragValue::new(&mut app.render_workspace.view.draft.height).range(1..=4096)).changed() { + app.render_workspace.view.dirty = true; + } + }); + let transparent_label = app.tr("render.image_transparent").to_string(); + ui.checkbox(&mut app.render_workspace.view.transparent, transparent_label); + if ui.button(app.tr("render.image_check")).clicked() { + let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() }; + if app.run("render.preflight", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})).is_ok() { + app.set_status(app.tr("render.image_supported").to_string(), false); + } + } + let mut busy = false; + if let Some(job) = app.render_workspace.view.job { + match app.session.execute("render.status", &json!({"job":job})) { + Ok(status) => { + let phase = status["progress"]["phase"].as_str().unwrap_or("unknown"); + busy = matches!(phase, "queued" | "preparing" | "running" | "encoding" | "cancelling"); + ui.label(match phase { + "queued" => app.tr("render.image_queued"), + "preparing" => app.tr("render.image_preparing"), + "running" => app.tr("render.image_running"), + "encoding" => app.tr("render.image_encoding"), + "cancelling" => app.tr("render.image_cancelling"), + "cancelled" => app.tr("render.image_cancelled"), + "completed" => app.tr("render.image_completed"), + "failed" => app.tr("render.image_failed"), + _ => app.tr("render.image_unknown"), + }); + if phase == "completed" { + app.render_workspace.view.completed = Some(job); + } + if let Some(message) = status["error"]["message"].as_str() { + ui.label(message); + if status["error"]["code"] == "limit_exceeded" { + ui.small(app.tr("render.image_reduce")); + } + } + if busy { + let done = status["progress"]["done"].as_u64().unwrap_or(0); + let total = status["progress"]["total"].as_u64().unwrap_or(1).max(1); + ui.add(egui::ProgressBar::new((done as f64 / total as f64).clamp(0.0, 1.0) as f32)); + ui.ctx().request_repaint(); + if ui.button(app.tr("render.image_cancel")).clicked() { + let _ = app.run("render.cancel", json!({"job":job})); + } + } + if let Some(revision) = status["scene_generation"].as_u64() + && revision != app.session.revision + { + ui.small(app.tr("render.image_stale")); + } + } + Err(e) => { + ui.label(e.to_string()); + } + } + } + if ui.add_enabled(!busy, egui::Button::new(app.tr("render.image_start"))).clicked() { + let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() }; + if let Ok(reply) = app.run("render.start", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})) { + app.render_workspace.view.job = serde_json::from_value(reply["job"].clone()).ok(); + ui.ctx().request_repaint(); + } + } + let overwrite_label = app.tr("render.image_overwrite").to_string(); + ui.checkbox(&mut app.render_workspace.view.overwrite, overwrite_label); + if ui.add_enabled(app.render_workspace.view.completed.is_some(), egui::Button::new(app.tr("render.image_export"))).clicked() + && let Some(job) = app.render_workspace.view.completed + { + export_image(app, job, app.render_workspace.view.overwrite); + } +} + +/// Keep the selected image and overwrite consent fixed while the upstream picker holds model input. +fn export_image(app: &mut SolveApp, job: solidcraft_engine::render::job::JobId, overwrite: bool) { + let origin = app.session.doc.clone(); + app.pick_save("render.png", &["png"], move |app, path| { + let Some(path) = path else { return }; + if !Arc::ptr_eq(&origin, &app.session.doc) { + app.set_status("The render export document changed while choosing its destination", true); + return; + } + let _ = app.run("render.export", json!({"job":job,"path":path,"overwrite":overwrite})); + }); +} + +/// Convert only product surfaces; geometry budgets are admitted before vertex allocation. +pub fn gpu_scene(scene: &ProductScene) -> Result<(u64, GpuScene), String> { + let bytes = scene + .meshes + .iter() + .try_fold(0usize, |n, m| m.mesh.triangles.len().checked_mul(3 * crate::gpu::PBR_SIZE).and_then(|x| n.checked_add(x))) + .ok_or("product geometry size overflow")?; + if bytes > MAX_GEOMETRY_BYTES { + return Err("product preview exceeds the 128 MiB geometry upload budget".into()); + } + let mut hash = std::collections::hash_map::DefaultHasher::new(); + let mut gpu = GpuScene::default(); + for body in &scene.meshes { + if body.faces.len() > 262_144 { + return Err("product preview exceeds the face-appearance admission cap".into()); + } + if !body.opacity.is_finite() + || !(0.0..=1.0).contains(&body.opacity) + || body.faces.iter().any(|f| !f.opacity.is_finite() || !(0.0..=1.0).contains(&f.opacity)) + { + return Err("invalid product appearance opacity".into()); + } + (Arc::as_ptr(&body.mesh) as usize).hash(&mut hash); + body.name.hash(&mut hash); + body.color.hash(&mut hash); + body.opacity.to_bits().hash(&mut hash); + hash_surface(body.surface, &mut hash); + let looks: std::collections::BTreeMap<_, _> = body.faces.iter().map(|l| (l.face, l)).collect(); + for face in &body.faces { + face.face.hash(&mut hash); + face.color.hash(&mut hash); + face.opacity.to_bits().hash(&mut hash); + hash_surface(face.surface, &mut hash); + } + for (index, triangle) in body.mesh.triangles.iter().enumerate() { + let face = body.mesh.tri_face.get(index).and_then(|f| looks.get(f)); + let (color, opacity, surface) = face.map_or((body.color, body.opacity, body.surface), |l| (l.color, l.opacity, l.surface)); + if surface.is_some() && opacity != 1.0 { + return Err("opaque PBR requires opacity 1".into()); + } + if opacity <= 0.0 { + continue; + } + let vertices = triangle.map(|i| { + let p = body.mesh.positions.get(i as usize)?.to_f32(); + let n = *body.mesh.normals.get(i as usize)?; + let n = if surface.is_some() { solidcraft_engine::render::surface::unit_direction(n).ok()? } else { n }.to_f32(); + p.into_iter().chain(n).all(f32::is_finite).then_some((p, n)) + }); + let [Some(a), Some(b), Some(c)] = vertices else { return Err("invalid product triangle".into()) }; + let rgba = [color[0], color[1], color[2], (opacity * 255.0).round() as u8]; + let (buffer, stride) = if surface.is_some() { + (&mut gpu.pbr, crate::gpu::PBR_SIZE) + } else if opacity < 0.999 { + (&mut gpu.glass, crate::gpu::TRI_SIZE) + } else { + (&mut gpu.tris, crate::gpu::TRI_SIZE) + }; + buffer.try_reserve(3 * stride).map_err(|_| "product preview vertex allocation failed".to_string())?; + for (p, n) in [a, b, c] { + if let Some(surface) = surface { + if n.iter().all(|v| *v == 0.0) { + return Err("PBR requires nonzero surface normals".into()); + } + gpu.pbr_tri(p, n, surface); + } else if opacity < 0.999 { + gpu.glass_tri(p, n, rgba); + } else { + gpu.tri(p, n, rgba); + } + } + } + } + Ok((hash.finish(), gpu)) +} + +fn hash_surface(surface: Option, hash: &mut impl Hasher) { + surface.is_some().hash(hash); + if let Some(s) = surface { + let c = s.base_color(); + for value in [c.x, c.y, c.z, s.metallic(), s.roughness(), s.dielectric_ior()] { + value.to_bits().hash(hash); + } + } +} + +pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { + let rect = ui.max_rect(); + app.viewport.rect = Some(rect); + let response = ui.allocate_rect(rect, Sense::click_and_drag()); + let delta = ui.input(|i| i.pointer.delta()); + if response.dragged_by(egui::PointerButton::Primary) { + app.cam.orbit(f64::from(delta.x), f64::from(delta.y)); + } + if response.dragged_by(egui::PointerButton::Middle) { + app.cam.pan(f64::from(delta.x), f64::from(delta.y), f64::from(rect.height())); + } + if response.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0.0 { + app.cam.zoom_at((-f64::from(scroll) * 0.0015 * crate::prefs::zoom_sign(app)).exp(), None); + } + } + let bg = app.render_workspace.view.draft.background; + ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); + let result = (|| -> Result<(), String> { + if app.viewport.gpu.is_none() { + return Err("GPU product preview is unavailable on this host. Saved setups remain available.".into()); + } + let ppp = ui.ctx().pixels_per_point(); + let setup = &app.render_workspace.view.draft; + if setup.exposure_ev != 0.0 || setup.environment_rotation != 0.0 || setup.environment_intensity != 1.0 { + return Err("fixed studio preview requires exposure 0, environment rotation 0 and intensity 1".into()); + } + let mut request = RenderRequest { + backend: Some(setup.backend.clone()), + mode: setup.mode, + operation: RenderOperation::Viewport, + width: (rect.width() * ppp).max(1.0) as u64, + height: (rect.height() * ppp).max(1.0) as u64, + format: None, + features: Vec::new(), + policy: Default::default(), + }; + solidcraft_engine::render::resolve_request(&[app.session.render_backend()], &request).map_err(|e| e.to_string())?; + if app.session.model.is_stale() { + return Err("finish the modelling gesture before previewing".into()); + } + let mut visibility = std::collections::hash_map::DefaultHasher::new(); + app.session.visibility.hidden_bodies.hash(&mut visibility); + app.session.visibility.hidden_occurrences.hash(&mut visibility); + let signature = (app.session.revision, Arc::as_ptr(&app.session.world_state()) as usize, visibility.finish()); + if app.render_workspace.scene_signature != Some(signature) { + let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?; + let (key, gpu) = gpu_scene(&scene)?; + if app.render_workspace.key != Some(key) { + if let Ok(mut slot) = app.render_workspace.slot.lock() { + *slot = Some(gpu); + } + app.render_workspace.key = Some(key); + } + app.render_workspace.scene = Some(scene); + app.render_workspace.scene_signature = Some(signature); + } + let scene = app.render_workspace.scene.as_ref().ok_or("no product scene")?; + request.features = scene.required_features(); + solidcraft_engine::render::resolve_request(&[app.session.render_backend()], &request).map_err(|e| e.to_string())?; + let key = app.render_workspace.key.ok_or("no product geometry")?; + if scene.bounds.is_empty() { + return Err("Create a visible solid in Design to preview it here.".into()); + } + let camera = app.cam; + let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target); + let eye = camera.eye(); + let slot = app.render_workspace.slot.clone(); + let callback = ViewportCallback { + product: true, + key, + slot: slot.clone(), + sketch_key: 0, + sketch_slot: slot.clone(), + hl_key: 0, + hl_slot: slot.clone(), + pv_key: 0, + pv_slot: slot, + view_proj: camera.view_proj(f64::from(rect.width() / rect.height().max(1.0)), radius).to_f32(), + back: camera.back().to_f32(), + size_px: [rect.width() * ppp, rect.height() * ppp], + clip: [0.0; 4], + cap: [0.0; 4], + images: Vec::new(), + analysis: [0.0; 8], + analysis_colors: [0.0; 36], + access: None, + outline: false, + eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }], + }; + ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback)); + Ok(()) + })(); + if let Err(error) = result { + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, error, egui::FontId::proportional(14.0), Tokens::get().text); + } +} diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs new file mode 100644 index 00000000..4645d703 --- /dev/null +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -0,0 +1,472 @@ +use super::*; +use crate::scenario::Harness; +use solidcraft_engine::Session; + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() { + use solidcraft_engine::render::{ + StandardView, + job::{CancelToken, ImageRequest, ImageTask, TaskPoll}, + }; + let mut s = Session::default(); + s.execute("solid.sphere", &json!({"radius":10,"body_name":"Original sphere"})).unwrap(); + const SIZE: u32 = 256; + let mut previous = None; + for (metallic, roughness) in [(0.0, 0.2), (0.0, 0.65), (1.0, 0.2), (1.0, 0.65)] { + s.execute( + "appearance.assign", + &json!({"bodies":["Original sphere"],"color":"#b47846", + "pbr":{"metallic":metallic,"roughness":roughness,"dielectric_ior":1.5}}), + ) + .unwrap(); + let scene = solidcraft_engine::product_scene::extract(&s).unwrap(); + let (key, gpu) = gpu_scene(&scene).unwrap(); + assert!(!gpu.pbr.is_empty() && gpu.tris.is_empty()); + assert_ne!(previous, Some(key)); + previous = Some(key); + let mut camera = Camera { fov: 0.0, ..Default::default() }; + camera.set_view(StandardView::Iso); + camera.fit(&scene.bounds); + let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target); + let eye = camera.eye().to_f32(); + let mut task = ImageTask::new( + scene, + ImageRequest { camera, width: SIZE, height: SIZE, background: [255; 3], transparent_background: false }, + CancelToken::default(), + ) + .unwrap(); + let mut png = None; + for _ in 0..100_000 { + if let TaskPoll::Complete(bytes) = task.step(4096).unwrap() { + png = Some(bytes); + break; + } + } + let cpu = image::load_from_memory(&png.expect("bounded original fixture completes")).unwrap().to_rgba8(); + assert_eq!(cpu.dimensions(), (SIZE, SIZE)); + let cpu = cpu.into_raw(); + for srgb in [false, true] { + let Some(gpu) = crate::gpu::render_product_offscreen( + gpu.clone(), + camera.view_proj(1.0, radius).to_f32(), + camera.back().to_f32(), + [eye[0], eye[1], eye[2], 0.0], + SIZE, + srgb, + ) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let at = |pixels: &[u8], x: usize, y: usize| { + let i = (y * SIZE as usize + x) * 4; + [pixels[i], pixels[i + 1], pixels[i + 2]] + }; + let mut core = 0usize; + let mut error = 0usize; + let mut outliers = 0usize; + for y in 2..SIZE as usize - 2 { + for x in 2..SIZE as usize - 2 { + // Exclude antialiased silhouettes: CPU uses 2x supersampling, GPU uses 1x. + if (y - 1..=y + 1).any(|ny| (x - 1..=x + 1).any(|nx| at(&cpu, nx, ny) == [255; 3] || at(&gpu, nx, ny) == [255; 3])) { + continue; + } + let delta = at(&cpu, x, y).into_iter().zip(at(&gpu, x, y)).map(|(a, b)| usize::from(a.abs_diff(b))).collect::>(); + error += delta.iter().sum::(); + outliers += usize::from(delta.iter().any(|d| *d > 12)); + core += 1; + } + } + assert!(core > 1000); + let mean = error as f64 / (3 * core) as f64; + eprintln!("PBR m={metallic} r={roughness} srgb={srgb}: core={core}, mean={mean}, outliers={outliers}"); + assert!(mean < 2.0 && outliers * 100 < core * 2, "CPU/GPU color or material interpretation differs"); + } + } +} + +#[test] +fn control_render_export_respects_upstream_file_scope() { + let mut h = Harness::new(); + let root = h.dir.join("allowed"); + let outside = h.dir.join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let roots = json!([root.to_string_lossy()]); + let response = h.call("ui.workspace", json!({"workspace":"render","allow_paths":roots})); + assert_eq!(response["ok"], true, "{response}"); + let response = h.call("engine.execute", json!({"command":"render.start","params":{"settings":{"width":16,"height":16}},"allow_paths":roots})); + assert_eq!(response["ok"], true, "{response}"); + let job = response["result"]["job"].clone(); + for _ in 0..50 { + h.frames(1); + } + assert_eq!(h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"], "completed"); + for path in [outside.join("denied.png"), root.join("../outside/traversal.png")] { + for overwrite in [false, true] { + let response = h.call( + "engine.execute", + json!({"command":"render.export","params":{"job":job,"path":path,"overwrite":overwrite},"allow_paths":roots}), + ); + assert_eq!(response["ok"], false, "{response}"); + assert!(response.to_string().contains("outside the folders this agent may use"), "{response}"); + } + } + assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0); + let path = root.join("allowed.png"); + let response = h.call("engine.execute", json!({"command":"render.export","params":{"job":job,"path":path},"allow_paths":roots})); + assert_eq!(response["ok"], true, "{response}"); + assert!(path.is_file()); + let original = std::fs::read(&path).unwrap(); + let response = h.call("engine.execute", json!({"command":"render.export","params":{"job":job,"path":path,"overwrite":true},"allow_paths":roots})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(std::fs::read(&path).unwrap(), original); +} + +#[test] +fn adopting_into_blank_document_resets_render_draft_and_preserves_hosts() { + let mut h = Harness::new(); + h.app.render_workspace.host = Some(host_executor(2048, true, 4).unwrap()); + sync_backend(&mut h.app); + h.app.render_workspace.view.name = "Old draft".into(); + enter(&mut h.app, true).unwrap(); + let mut incoming = Session::default(); + incoming.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); + crate::documents::adopt(&mut h.app, incoming); + assert!(!active(&h.app)); + assert_eq!(h.app.render_workspace.view.name, "Studio"); + assert_eq!(h.app.session.render_backend().executors.len(), 3); + assert!(h.app.session.render_job_host().is_some()); + assert_eq!(h.app.session.model.state().bodies.len(), 1); +} + +#[test] +fn clicked_image_controls_complete_cancel_and_export_without_dirtying_the_design() { + for language in ["en", "de", "ko", "ru"] { + let mut h = Harness::new(); + h.app.preferences.language = language.into(); + h.frames(1); + let check = h.app.tr("render.image_check").to_string(); + let start = h.app.tr("render.image_start").to_string(); + let export = h.app.tr("render.image_export").to_string(); + let cancel = h.app.tr("render.image_cancel").to_string(); + h.call("ui.resize", json!({"width":1600,"height":1400})); + enter(&mut h.app, true).unwrap(); + h.app.render_workspace.view.draft.width = 16; + h.app.render_workspace.view.draft.height = 16; + h.app.render_workspace.view.dirty = true; + h.frames(3); + let doc = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); + h.step(&json!({"widget":check})).unwrap(); + h.step(&json!({"widget":start})).unwrap(); + for _ in 0..50 { + h.frames(1); + if h.app.render_workspace.view.completed.is_some() { + break; + } + } + let good = h.app.render_workspace.view.completed.unwrap(); + h.step(&json!({"widget":export})).unwrap(); + assert!(h.dir.join("render.png").is_file()); + h.app.render_workspace.view.draft.width = 1024; + h.app.render_workspace.view.draft.height = 768; + h.step(&json!({"widget":start})).unwrap(); + h.step(&json!({"widget":cancel})).unwrap(); + h.frames(5); + assert_eq!(h.app.render_workspace.view.completed, Some(good)); + assert_eq!( + h.app.session.execute("render.status", &json!({"job":h.app.render_workspace.view.job})).unwrap()["progress"]["phase"], + "cancelled" + ); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + assert_eq!(h.app.session.undo.len(), undo); + assert!(!h.app.session.is_dirty()); + } +} + +#[test] +fn saved_camera_survives_frames_and_product_fit_ignores_selection() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":20,"height":5,"body_name":"Product"})).unwrap(); + h.app.run("solid.box", json!({"corner":[100,0,0],"length":10,"width":20,"height":5,"body_name":"Other"})).unwrap(); + let camera = Camera { up: solidcraft_engine::render::UpAxis::Y, roll: 0.2, ..Default::default() }; + h.app.run("render.settings.set", json!({"name":"Studio","set":{"camera":camera,"background":[21,43,65]}})).unwrap(); + h.frames(3); + let design = h.app.cam; + enter(&mut h.app, true).unwrap(); + h.frames(3); + h.step(&json!({"widget":"Restore Saved Camera"})).unwrap(); + h.frames(3); + assert_eq!(h.app.cam, camera); + assert_eq!(h.app.render_workspace.view.draft.background, [21, 43, 65]); + h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Product".into() }]; + let bounds = solidcraft_engine::product_scene::extract(&h.app.session).unwrap().bounds; + let mut expected = camera; + expected.fit(&bounds); + h.app.animate_view("fit"); + h.frames(3); + assert_eq!(h.app.cam, expected); + enter(&mut h.app, false).unwrap(); + assert_eq!(h.app.cam, design); +} + +#[test] +fn clicked_render_workspace_saves_one_setup_and_restores_design_view() { + for language in ["en", "de", "ko", "ru"] { + let mut h = Harness::new(); + h.app.preferences.language = language.into(); + h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap(); + h.app.session.mark_saved(); + h.frames(3); + let camera = h.app.cam; + let flags = (h.app.ui.show_grid, h.app.ui.show_origin, h.app.ui.visual_style, h.app.ui.perspective); + let undo = h.app.session.undo.len(); + let at = crate::scenario::rect_of("workspace_switcher").unwrap().center(); + h.step(&json!({"click":[at.x,at.y]})).unwrap(); + h.step(&json!({"click":{"handle":"menu:ui.workspace.render"}})).unwrap(); + assert!(active(&h.app)); + let heading = h.app.tr("cmd.render_settings_get").to_string(); + let perspective = h.app.tr("render.perspective").to_string(); + let save = h.app.tr("render.save_setup").to_string(); + let back = h.app.tr("render.return_design").to_string(); + assert!(h.widget(&heading).is_some(), "{language}"); + h.step(&json!({"widget":perspective})).unwrap(); + assert!(!h.app.session.is_dirty(), "preview edits are staged"); + h.step(&json!({"widget":save})).unwrap(); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert_eq!(h.app.session.doc.render.setups["Studio"].camera, h.app.cam); + h.step(&json!({"widget":back})).unwrap(); + assert!(!active(&h.app)); + assert_eq!(h.app.cam, camera); + assert_eq!(flags, (h.app.ui.show_grid, h.app.ui.show_origin, h.app.ui.visual_style, h.app.ui.perspective)); + h.app.run("edit.undo", json!({})).unwrap(); + assert!(!h.app.session.is_dirty()); + } +} + +#[test] +fn render_drafts_and_capabilities_are_isolated_across_document_switches() { + let mut h = Harness::new(); + h.app.render_workspace.host = Some(host_executor(2048, true, 4).unwrap()); + sync_backend(&mut h.app); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.render_workspace.view.name = "First draft".into(); + h.app.render_workspace.view.draft.background = [20, 40, 60]; + h.app.render_workspace.view.dirty = true; + enter(&mut h.app, true).unwrap(); + crate::documents::open_tab(&mut h.app, Session::default()).unwrap(); + assert!(!active(&h.app)); + assert_eq!(h.app.render_workspace.view.name, "Studio"); + assert_eq!(h.app.session.render_backend().executors.len(), 3); + crate::documents::switch(&mut h.app, 0); + assert_eq!(h.app.render_workspace.view.name, "First draft"); + assert_eq!(h.app.render_workspace.view.draft.background, [20, 40, 60]); + assert!(h.app.render_workspace.view.dirty); + assert_eq!(h.app.session.render_backend().executors.len(), 3); + h.app.run("file.new", json!({})).unwrap(); + assert_eq!(h.app.render_workspace.view.name, "Studio"); + assert_eq!(h.app.session.render_backend().executors.len(), 3); +} + +#[test] +fn product_gpu_buffers_preserve_opacity_and_never_contain_overlays() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Glass"})).unwrap(); + s.execute("appearance.assign", &json!({"bodies":["Glass"],"color":"#336699","opacity":0.4})).unwrap(); + let scene = solidcraft_engine::product_scene::extract(&s).unwrap(); + let (key, gpu) = gpu_scene(&scene).unwrap(); + assert!(gpu.tris.is_empty()); + assert!(!gpu.glass.is_empty()); + assert!(gpu.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>().0.iter().all(|v| v[24..] == [51, 102, 153, 102])); + assert!(gpu.lines.is_empty() && gpu.overlay.is_empty() && gpu.under.is_empty() && gpu.hidden.is_empty() && gpu.ghost.is_empty()); + s.selection = vec![solidcraft_engine::Sel::Body { name: "Glass".into() }]; + s.section = Some((solidcraft_engine::geom::Vec3::ZERO, solidcraft_engine::geom::Vec3::X)); + let after = solidcraft_engine::product_scene::extract(&s).unwrap(); + assert_eq!(gpu_scene(&after).unwrap().0, key); + s.execute("appearance.assign", &json!({"bodies":["Glass"],"color":"#336699","opacity":0.0})).unwrap(); + let invisible = solidcraft_engine::product_scene::extract(&s).unwrap(); + assert_eq!(invisible.meshes[0].opacity, 0.0); + let (_, empty) = gpu_scene(&invisible).unwrap(); + assert!(empty.tris.is_empty() && empty.glass.is_empty()); +} + +#[test] +fn host_profiles_advertise_only_installed_opaque_pbr_not_hdri_refraction_or_final() { + use solidcraft_engine::render::*; + let host = host_executor(512, true, 4).unwrap(); + assert_eq!(host.limits.max_dimension, 512); + assert_eq!(host.profiles.len(), 1); + assert_eq!(host.profiles[0].features, vec![RenderFeature::Pbr]); + assert!(host.profiles[0].formats.is_empty()); + let mut s = Session::default(); + s.set_render_viewport(Some(host)).unwrap(); + let mut request = RenderRequest { + backend: None, + mode: RenderMode::Preview, + operation: RenderOperation::Viewport, + width: 512, + height: 512, + format: None, + features: Vec::new(), + policy: Default::default(), + }; + assert_eq!(resolve_request(&[s.render_backend()], &request).unwrap().executor.as_str(), "gpu-viewport"); + request.features = vec![RenderFeature::Pbr]; + assert!(resolve_request(&[s.render_backend()], &request).is_ok()); + for unsupported in [RenderFeature::Hdri, RenderFeature::Refraction, RenderFeature::Progressive] { + request.features = vec![unsupported]; + assert!(resolve_request(&[s.render_backend()], &request).is_err()); + } + request.features.clear(); + request.width = 513; + assert!(resolve_request(&[s.render_backend()], &request).is_err()); + request.width = 512; + request.mode = RenderMode::Final; + assert!(resolve_request(&[s.render_backend()], &request).is_err()); + assert!(host_executor(1024, false, 1).unwrap().availability != Availability::Available); + assert!(host_executor(1024, true, 0).is_err()); +} + +fn completed_preview(h: &mut Harness, background: [u8; 3]) -> solidcraft_engine::render::job::JobId { + let reply = h.app.session.execute("render.start", &json!({"settings":{"width":16,"height":16,"background":background}})).unwrap(); + let job = serde_json::from_value(reply["job"].clone()).unwrap(); + let host = h.app.session.render_job_host().unwrap().clone(); + for _ in 0..100 { + host.pump(16384).unwrap(); + if h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"] == "completed" { + return job; + } + } + panic!("small cooperative image did not complete within the work budget"); +} + +fn async_export_fixture() -> (Harness, solidcraft_engine::render::job::JobId) { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap(); + h.app.session.mark_saved(); + let job = completed_preview(&mut h, [200, 210, 220]); + (h, job) +} + +#[test] +fn delayed_image_export_holds_its_document_without_changing_history() { + use crate::picker_test_support::{Slot, save}; + let (mut h, job) = async_export_fixture(); + let path = h.dir.join("delayed.png"); + let doc = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); + let ctx = egui::Context::default(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + export_image(&mut h.app, job, false); + crate::file_picker::tick(&mut h.app, &ctx); + assert!(h.app.file_picker_busy() && !path.exists()); + assert_eq!(h.call("ui.inspect", json!({}))["result"]["file_dialog_open"], true); + crate::documents::new_design(&mut h.app); + let other = h.app.session.doc.clone(); + *slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned())); + crate::file_picker::tick(&mut h.app, &ctx); + assert_eq!(h.app.docs.active, 0, "upstream holds tab changes while a picker is pending"); + assert_eq!(h.call("ui.inspect", json!({}))["result"]["file_dialog_open"], false); + assert!(Arc::ptr_eq(&other, &h.app.session.doc)); + assert_eq!(&std::fs::read(&path).unwrap()[..8], b"\x89PNG\r\n\x1a\n"); + crate::documents::switch(&mut h.app, 0); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + assert_eq!(h.app.session.undo.len(), undo); + assert!(!h.app.session.is_dirty()); +} + +#[test] +fn delayed_image_export_cancel_and_overwrite_choice_preserve_existing_files() { + use crate::picker_test_support::{Slot, save}; + let (mut h, job) = async_export_fixture(); + let path = h.dir.join("existing.png"); + std::fs::write(&path, b"existing contents").unwrap(); + let doc = h.app.session.doc.clone(); + let ctx = egui::Context::default(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + export_image(&mut h.app, job, false); + crate::file_picker::tick(&mut h.app, &ctx); + *slot.borrow_mut() = Some(None); + crate::file_picker::tick(&mut h.app, &ctx); + assert!(!h.app.file_picker_busy()); + assert_eq!(std::fs::read(&path).unwrap(), b"existing contents"); + export_image(&mut h.app, job, false); + crate::file_picker::tick(&mut h.app, &ctx); + h.app.render_workspace.view.overwrite = true; + *slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned())); + crate::file_picker::tick(&mut h.app, &ctx); + assert_eq!(std::fs::read(&path).unwrap(), b"existing contents", "a later checkbox change is not overwrite consent for the pending export"); + assert!(matches!(&h.app.status, Some((_, _, true)))); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + assert_eq!(h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"], "completed"); +} + +#[test] +fn delayed_image_export_never_substitutes_a_newer_retained_image() { + use crate::picker_test_support::{Slot, save}; + let (mut h, job) = async_export_fixture(); + let path = h.dir.join("old-image.png"); + let ctx = egui::Context::default(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + export_image(&mut h.app, job, false); + crate::file_picker::tick(&mut h.app, &ctx); + let newer = completed_preview(&mut h, [20, 30, 40]); + h.app.render_workspace.view.completed = Some(newer); + *slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned())); + crate::file_picker::tick(&mut h.app, &ctx); + assert!(!path.exists(), "the evicted requested image must fail instead of exporting the newer image"); + assert!(matches!(&h.app.status, Some((_, _, true)))); + assert_eq!(h.app.render_workspace.view.completed, Some(newer)); + let newer_path = h.dir.join("new-image.png"); + h.app.run("render.export", json!({"job":newer,"path":newer_path})).unwrap(); + assert!(newer_path.is_file()); +} + +#[test] +fn delayed_image_export_holds_close_and_new_document_until_cancel() { + use crate::picker_test_support::{Slot, save}; + let (mut h, job) = async_export_fixture(); + let path = h.dir.join("closed.png"); + let ctx = egui::Context::default(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + export_image(&mut h.app, job, false); + crate::file_picker::tick(&mut h.app, &ctx); + crate::documents::new_design(&mut h.app); + crate::documents::close(&mut h.app, 0, true); + let other = h.app.session.doc.clone(); + assert_eq!(h.app.docs.count(), 1); + assert!(h.app.file_picker_busy()); + *slot.borrow_mut() = Some(None); + crate::file_picker::tick(&mut h.app, &ctx); + assert!(!path.exists() && !h.app.file_picker_busy()); + assert!(Arc::ptr_eq(&other, &h.app.session.doc)); +} + +#[test] +fn delayed_image_export_refuses_an_internally_replaced_document_even_with_overwrite() { + use crate::picker_test_support::{Slot, save}; + for overwrite in [false, true] { + let (mut h, job) = async_export_fixture(); + let path = h.dir.join("keep.png"); + std::fs::write(&path, b"keep this file").unwrap(); + let slot: Slot = Default::default(); + save(&mut h.app, &slot); + export_image(&mut h.app, job, overwrite); + assert!(h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[1,2,3]})).is_err()); + // Internal host/session changes are outside the input hold; never export them by accident. + h.app.session.execute("appearance.assign", &json!({"bodies":["Body1"],"color":[1,2,3]})).unwrap(); + let changed = h.app.session.doc.clone(); + let history = (h.app.session.undo.len(), h.app.session.revision); + *slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned())); + crate::file_picker::tick(&mut h.app, &egui::Context::default()); + assert_eq!(std::fs::read(path).unwrap(), b"keep this file"); + assert!(Arc::ptr_eq(&changed, &h.app.session.doc)); + assert_eq!((h.app.session.undo.len(), h.app.session.revision), history); + assert!(matches!(&h.app.status, Some((_, _, true)))); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 110fe306..27831067 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -57,6 +57,7 @@ pub fn panels(tab: &str) -> &'static [&'static str] { "MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], "SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], "PLASTIC" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], + "RENDER" => &["SCENE"], _ => &["MAKE", "ADD-INS", "UTILITY", "INSPECT", "SELECT"], } } @@ -505,7 +506,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { painter.text( pos2(ws.center().x - 5.0, ws.top() + 52.0), Align2::CENTER_CENTER, - app.tr_label(crate::toolbar_custom::WORKSPACE), + app.tr_label(if crate::render_workspace::active(app) { "RENDER" } else { crate::toolbar_custom::WORKSPACE }), FontId::proportional(12.0), t.text, ); @@ -521,7 +522,11 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { if sketching { tabs.push("SKETCH"); } - tabs.extend(TABS.iter()); + if crate::render_workspace::active(app) { + tabs.push("RENDER"); + } else { + tabs.extend(TABS.iter()); + } for tab in tabs { let display_tab = app.tr_label(tab); let text_width = painter.layout_no_wrap(display_tab.to_string(), FontId::proportional(12.0), t.text).size().x; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 86e833ca..d6557c7d 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2258,6 +2258,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } let ppp = ui.ctx().pixels_per_point(); let cb = ViewportCallback { + product: false, key: app.viewport.key, slot: app.viewport.slot.clone(), sketch_key: app.viewport.sketch_key, diff --git a/docs/book/src/dev/local-libraries.md b/docs/book/src/dev/local-libraries.md index 65f0f4e8..fc7eda16 100644 --- a/docs/book/src/dev/local-libraries.md +++ b/docs/book/src/dev/local-libraries.md @@ -24,6 +24,14 @@ unless **Replace existing library file** is explicitly selected. Export is a sna the design definitions, not an automatic merge with entries in an existing catalog. Editing a copied look/material uses the normal commands and does not edit its source. +The combined render integration uses main's merged #1127 asynchronous picker. +Model edits and tab changes wait for its answer while drawing/resizing continues. +Overwrite consent is captured before opening; export snapshots the design when +`library.export` runs after choosing the destination. Document identity is checked, +and an old library answer cannot replace another Appearance editor. Cancellation +does not read or write a library. The earlier #1114 candidate is no longer a runtime +dependency of the refreshed consumer. + The desktop panel applies appearances. Physical-material import/assignment is available through `library.apply`; imported definitions also appear in the existing material list. The browser can export a library through the existing download service. Interactive local @@ -123,3 +131,8 @@ The copied design remains independent after successful assignment, undo/redo and reopening. This library has no dependency on the pending render stack and does not change the document format. User-visible evidence and actual native/wasm results are recorded in the PR; geometry/robustness checks remain separate and no reference baseline is weakened. + +The opaque PBR consumer makes v1 export fail explicitly when any body, component, +face or material-default appearance contains PBR parameters. Neither overwrite consent +nor an existing output file permits a lossy downgrade; use the richer transport +when available. Scalar catalog imports still select the scalar response explicitly. diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 0f1c4491..c90d83d6 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -73,7 +73,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio The default `cache: false` omits the portable thumbnail too. The repository's `docs/design-container.md` describes limits and encoding details. -Current designs use format 21, including design properties, named render setups, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis +Current designs use format 21, including design properties, named render setups, opaque PBR looks, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis links, persistent names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and association profile references, durable sketch face anchors, periodic fit splines and versioned diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e5c98082..8c8e60dc 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -71,6 +71,10 @@ MCP server sends its folders this way on every request ([mcp.md](mcp.md)). Witho Commands never open dialogs when run this way. `solidcraft-cli commands` prints the same registry. +Product images use `render.preflight/start/status/cancel/export` through `engine.execute`. +Keep the returned session-scoped job ID for subsequent calls; status does not block or +render. See [render jobs](render-jobs.md) for capture, cancellation and export semantics. + While an interactive native file picker is open, `ui.inspect` reports `file_picker_busy: true`. Engine commands, `ui.file` and `ui.home` requests wait for its answer; window resizing, inspection and screenshots remain available. @@ -97,7 +101,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | Method | Params | Notes | |---|---|---| -| `ui.inspect` | — | UI state, what is hidden (`visibility`: bodies, origin and construction items, sketches, joints (`hidden_joints`, joint ids); set it with the `browser.visibility` command), viewport rect, camera, active tool/dialog, the status line (`status`: text and whether it is an error), hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | +| `ui.inspect` | — | UI state, pending file picker (`file_picker_busy`; compatibility alias `file_dialog_open`, no path), what is hidden (`visibility`: bodies, origin and construction items, sketches, joints (`hidden_joints`, joint ids); set it with the `browser.visibility` command), viewport rect, camera, active tool/dialog, the status line (`status`: text and whether it is an error), hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | @@ -134,6 +138,10 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.resize` | `width`, `height` | | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) | +`ui.workspace {workspace?: "design" | "render"}` reads or changes the active workspace. +Entering Render requires finishing the current modelling operation; returning restores +the Design camera. See [the integrated Render workspace](render-workspace.md). + ## MCP `solidcraft-cli mcp --connect 127.0.0.1:PORT` bridges an MCP client (an AI agent) to this diff --git a/docs/features.md b/docs/features.md index 6aa4a773..42b4acec 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **354 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **363 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (99) @@ -517,13 +517,20 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Insert Part | `parts.insert` | – | | Change Part Size | `parts.resize` | – | -## `render.*` (3) +## `render.*` (10) | Command | Id | Toolbar | |---|---|---| +| Check Render Request | `render.preflight` | – | +| Render Image | `render.start` | RENDER › IMAGE | +| Render Progress | `render.status` | – | +| Cancel Render | `render.cancel` | – | +| Export Render Image | `render.export` | – | | Render Backends | `render.backends` | – | | Render Settings | `render.settings.get` | – | | Save Render Settings | `render.settings.set` | – | +| Inspect Render Scene | `render.scene` | RENDER › SCENE | +| Apply Render Camera | `render.settings.apply` | – | ## `select.*` (2) diff --git a/docs/mcp.md b/docs/mcp.md index d9214640..3e0e582d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5,6 +5,11 @@ SolidCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) An agent models exactly like a person does: every tool runs engine commands, the same ones the toolbar, palette, scripts and control channel use, and never opens a dialog. +Persistent headless and bridged sessions expose asynchronous product images through the +ordinary `execute` tool: `render.preflight`, `render.start`, `render.status`, +`render.cancel` and `render.export`. Keep the complete returned job ID; image bytes remain +in the session until explicit PNG export. See [render jobs](render-jobs.md). + ## Start it ```sh diff --git a/docs/render-backends.md b/docs/render-backends.md index 21eeaf2f..e80878e5 100644 --- a/docs/render-backends.md +++ b/docs/render-backends.md @@ -1,5 +1,9 @@ # Render backend capabilities (R1) +This page describes the R1 metadata contract. The installed GPU viewport and CPU +product-job hosts extend it through complete profiles; HDR input preparation adds +no lighting capability. See [the integrated phase boundaries](render-integration.md). + `render.backends` discovers render metadata without opening a window, probing a GPU, evaluating the model or changing the document, camera, dirty state or undo/redo history. It also leaves a model made stale by a live sketch gesture stale. @@ -55,11 +59,13 @@ descriptors, not design-file settings. Executor IDs are scoped to their backend. Backend/executor IDs are 1–64 ASCII bytes: a lowercase letter followed by lowercase letters, digits, `.`, `_` or `-`. Both construction and deserialization validate IDs. -The integrated backend currently describes only the existing CPU raster snapshot +Without runtime hosts the integrated backend describes the existing CPU raster snapshot path. Its `preview`/`image` profile exports PNG; it does not claim HDRI, PBR, refraction, progressive rendering or a final-quality integrator. The existing GPU -viewport remains separate and is not advertised here until a GPU host can register -and probe its actual capabilities. CPU and GPU are execution paths, not separate +viewport registers separately when the UI installs its actual device/pipelines. +The installed GPU product viewport and CPU product-image host support the subsequent +[opaque PBR consumer](render-opaque-pbr.md); this does not alter the static raster +descriptor or enable HDRI/refraction/final rendering. CPU and GPU are execution paths, not separate user-selectable engine families. ## Pure request resolution diff --git a/docs/render-integration.md b/docs/render-integration.md new file mode 100644 index 00000000..f964acf2 --- /dev/null +++ b/docs/render-integration.md @@ -0,0 +1,97 @@ +# Phased render and material integration + +Tracked in #842, with parent render design #620 and physical/visual material roadmap +#922. The combined integration keeps contribution ancestry; it is an alternative +review path, not evidence that the individual PRs have merged. Each result in GitHub +is pinned to its tested source revision. Do not reuse an older green result for a new +merge or describe compiled wasm as verified browser interaction. + +## Delivery phases + +| Phase | Contribution / deliverable | Acceptance gate | +|---|---|---| +| 1 | R2 #667, R3 #706, R4a #800: document-owned setups, product viewport, bounded CPU PNG jobs | Setup migration/save/reopen/undo, document and job isolation, strict capabilities, cancellation, native evidence and repository gates | +| 2 | Merged HDR input #837 and surface reference #1019 | Bounded codec/BRDF tests; unchanged existing CPU capabilities until actual consumers exist | +| 3 | Opaque product PBR #1046; engineering-card persistence #1060 | Explicit supported optical fields, source/condition preservation, one-step undo, cold reopening, unchanged geometry and independent assigned density | +| 4 | Merged scalar catalogs #863; richer transport #1086; inspector/import #1094 | Exact selected revisions, copied definitions, hostile-input bounds, no lossy downgrade, scoped file access, reviewed import and actual native evidence | +| 5 | Environment consumer #813, then texture/wood mapping #1004/#786 using resource admission #909 | Actual linear/sRGB interpretation and lighting, persistent object-local frames, cancellation/memory bounds, transformed instances, separate native/browser evidence | +| 6 | Family-specific optical/data quality under #922 | Wood L/R/T and moisture/grade conditions; polymer grade/process/temperature/rate; glass thickness/IOR/absorption; metal alloy/temper/conductor response and brushed anisotropy; source/license review before shipping data | + +The released document format is 20 (v0.11.0); render setups and opaque PBR looks are saved +under format 21. Saved render setups migrate from older formats with older-reader refusal. The Design +viewport retains upstream display-plan tessellation; Render uses its separate +prepared product meshes. Neither runtime jobs nor device handles are persisted. +See the versioned contracts rather than reserving another format independently. + +## Current foundation boundary + +The GPU host provides a product **viewport**. The CPU product executor provides +bounded preview **PNG images**, with opt-in opaque PBR and approximate sorted transparency. +The [fixed studio consumer](render-opaque-pbr.md) provides direct lighting only; +it has no HDRI, cast shadows, transmission or indirect bounces. The Radiance +utility prepares linear RGB in the source colour space; it is not lighting. The +numerical opaque surface contract is not proof that a host renders those properties. +Imported resources or saved intent never silently add HDRI/refraction/final/EXR +support. Unsupported requests fail and preserve the last valid image and CAD state. +See [backends](render-backends.md), [settings](render-settings.md), +[workspace](render-workspace.md), [jobs](render-jobs.md), +[HDR input](render-hdr-input.md) and [surface reference](render-surface.md). + +Both atomic PNG publication modes check the existing caller file scope. The +control-channel regression rejects outside folders and traversal and permits a +valid destination, including explicit overwrite. This guard and document-adoption +regression now belong to standalone R4a, so its safety does not depend on this +combined integration. Render engine and scene production code match that foundation. The combined UI +additionally consumes the async picker candidate described below; it does not add +a second renderer or file-access service. + +## Picker integration before release + +The merged upstream #1127 service (`file_picker`) replaces the earlier #1114 +candidate in this branch. There is one picker service, provided by main. Its future +returns a selected path or cancellation; the event loop continues drawing/resizing +while model input, tab switching/closing and control engine commands wait. +The compatibility inspection flag `file_dialog_open` mirrors `file_picker_busy` +and exposes no paths. Render and library callbacks capture overwrite consent and +retain explicit job/editor/document identity. An evicted image fails instead of +substituting a newer result. Cancellation writes nothing. Source/host changes that +replace the requesting document refuse the export; a replaced editor cannot receive +an old library response. Controlled future regressions cover these boundaries. + +Earlier native responsiveness evidence and tab-routing tests used #1114's candidate +and keep their original pins. They are not proof of the new hold-input policy. +The upstream service's own tests cover held input and resize frames; add native +review evidence before describing the refreshed product workflow as GUI-verified. + +## Material, appearance and resource ownership + +Physical properties, assigned density, visual appearances and environment lighting +are distinct. A card-only edit does not replace assigned density or visual style; +applying an appearance does not change mass. Missing engineering values stay unknown, +with per-property sources and measurement conditions. Numerical consistency is not +certification or a structural solver. Mechanical axes are not inferred from a texture. + +The first scalar catalog remains usable without optical/engineering extensions. +Richer transport must preserve copied PBR/card records and reject lossy downgrade; +reopening a design must not require the source catalog. Keep source revisions, +conflicts and applicability explicit. All inspection/application uses existing engine +commands; programmatic calls never open pickers. Library export edits the reusable +file rather than the CAD design. Applying a definition is one undoable design edit. + +Use original synthetic fixtures first. A licence string in imported data is a source +claim, not redistribution permission. Any shipped dataset or texture needs actual +source/license review and attribution. Commercial reference artwork is not copied. +No second renderer, certified material database or simulation solver is implied. + +## Verification and collaboration + +Run `cargo xtask ci` before each committed arc. Record local/remote results, pinned +upstream, actual platform and failures separately. Do not weaken geometry baselines, +mask warnings or infer whole-app performance from cached capture/cancellation times. +Attach actual app/PNG evidence for visible behavior; label older evidence with its +source revision instead of relabeling it as current. Use scoped original examples. + +The private `plan/` documents are unavailable in this checkout; no content is +invented. Public contracts and current instructions govern this work. Propose bounded +interfaces in GitHub where a handoff is missing, invite feedback, and check existing +claims/PRs before implementing. Keep independent contributor branches untouched. diff --git a/docs/render-jobs.md b/docs/render-jobs.md new file mode 100644 index 00000000..4bd8a69a --- /dev/null +++ b/docs/render-jobs.md @@ -0,0 +1,119 @@ +# Product-image jobs (R4a) + +R4a adds bounded CPU preview images to the Render workspace. The installed `builtin` / +`cpu-product` executor supports Preview, Image and PNG. It does not promise refraction, +HDRI, exposure, final mode or EXR; those requests fail explicitly. The subsequent +[opaque PBR consumer](render-opaque-pbr.md) adds opt-in direct-lit surfaces through +the same host. Legacy scalar looks and the Design snapshot renderer retain their +existing shading. + +## One command path + +`render.preflight` and `render.start` take `setup` (a saved name) OR `settings` (a +RenderSetup object; omitted fields use its documented defaults), plus optional +`transparent_background` and `replace` booleans, both false by default. Without either +setup/settings, the current requested camera is used, or the camera is fitted to the +product. Saving a setup is independent of starting a job. Unknown fields are errors. + +Both commands reject stale geometry without settling a live modelling gesture. Finish +the gesture or use the explicit `render.scene` boundary first. Capture uses R3's +authoritative visible meshes, placements and appearances, without selection, edges, +axes, grids, sections or analysis overlays. A cold capture can prepare world geometry +and meshes synchronously; it is not a promise of bounded CAD evaluation latency. +Preflight validates capabilities and estimates admission, but does not reserve a queue +slot or the host memory pool. Start checks those resources again. + +Start returns a typed `job: {session, serial, generation}`. Keep all three fields when +calling `render.status`, `render.cancel` or `render.export`. IDs are never accepted by +another document session, scratch copy, or a newly opened/recovered document. Status +does no rendering and never waits for completion. It reports queued, preparing, +running, encoding, cancelling, cancelled, completed or failed, with phase-local +`done/total`, structured job errors and completed frame metadata. Progress restarts +when a phase changes; it is not a time estimate. + +The captured `scene_generation` is the session revision at start. Later edits, undo, +camera changes and tab switches do not mutate the snapshot. An image continues to +describe that captured scene; the UI labels a revision mismatch conservatively (view +cache invalidation can also advance a revision). Runtime state is outside the document, +undo, checkpoints and the design format. Closing/replacing a session cancels its work; +the worker cannot publish a cancelled frame. Direct host-side document replacement +preserves the installed image service while allocating fresh job ownership; old IDs +and completed images never cross into the new document. `replace: true` cancels retained active +and pending jobs before submitting the new request. A cancelled generation cannot +publish late. A successful older generation never replaces a newer successful result. + +Each document retains at most two live jobs, four recent terminal status records and +one last successful image. Cancellation/failure preserves that image. A new successful +image releases the previous image; old terminal metadata can still report completion, +but it does not promise that its bytes remain exportable. Export requires an explicit +retained completed job ID; it never silently exports a different/newer job. + +## Native and browser execution + +Each application installs one CPU host, shared by its documents. Native execution uses +one worker and at most two queued inputs across documents; it runs without UI polling. +The browser uses the same incremental task, pumped by UI frames, without threads. +Preparation, stable transparency sorting, clearing, pixel tests and downsampling check +cancellation between small units. A step admits at most 16384 units; PNG encoding +additionally yields after four rows. The UI pumps 1024 units per frame. These are work +bounds, not hard real-time guarantees: allocation, codec compression and OS scheduling +still have platform-dependent latency. + +Admission caps output at 4096 per dimension and 4 million pixels; pixel scratch is +estimated at 48 bytes per output pixel (2x supersampling). Other limits: 262144 +triangles/face overrides/edge polylines, 128 MiB retained source geometry, 384 MiB total +estimated memory per job, 512 MiB shared host admission including retained PNGs, and +128 million projected pixel tests. The projected-work limit is checked during bounded +preparation and may fail an admitted job. Encoded PNG bytes have an additional +`8 * output_pixels + 65536` cap. Allocator and codec internals are estimates, not an +operating-system memory quota. Limits fail with errors rather than allocating unbounded +buffers. Completed frames keep their memory reservation while bytes are retained. + +The CPU path shades in linear light, blends sorted triangle alpha approximately, then +encodes 8-bit sRGB with straight alpha and an sRGB PNG chunk. Transparent shared edges +use a consistent fill rule. Intersecting transparent surfaces and triangle sorting +remain raster approximations. This is not a glass/refraction renderer. Native GPU +preview and CPU export can differ in shading and transparency. + +PNG uses the already-transitive Rust `png` crate (MIT OR Apache-2.0), now an explicit +render dependency. No external render engine or third-party art is introduced. + +## Export and automation + +`render.export` takes `job`, `path` ending in `.png`, and `overwrite` (default false). +The destination is checked against the caller's existing file-access scope before +reading the retained frame or calling either native writer. Disallowed folders and +traversal are refused in both overwrite modes. Native no-clobber export writes/syncs a temporary beside the target and publishes it +with an atomic hard link; an existing destination wins even in a race. Filesystems +without that operation return an error. Explicit overwrite uses the existing atomic +writer without reading or backing up the previous target. Failed writes preserve the completed +image and CAD state. Browser export uses the existing virtual file/download service; +no filesystem paths or base64 payloads are persisted in the design. + +The Render panel provides Check Render Request, Start Render, phase progress, +Cancel Render and Export PNG. Replace existing file is an explicit opt-in. A failed or +cancelled new render leaves the previous successful export available. + +Native PNG export in the combined integration uses the document-bound asynchronous +picker from #1114. The requested job ID and overwrite choice are captured before the +dialog opens: cancellation writes nothing, closing the originating document drops +the answer, and an evicted image fails instead of substituting a newer result. A tab +switch does not redirect the export to another document. + +Controls and phase messages use stable offline catalog IDs in English, Japanese, Chinese, +German and Korean; command IDs and scenario handles remain unchanged. + +```sh +solidcraft-cli render examples/render-studio.json --out product.png --width 800 --height 600 +solidcraft-cli render design.solidcraft --setup Studio --out product.png --transparent +``` + +The CLI follows preflight/start/native wait/export, with a default 120-second wait +limit (`--timeout 1..3600`). Timeout requests cancellation. `--overwrite` is required +to replace an existing target. A persistent headless MCP session installs the same +host: use the ordinary `execute` tool with the five commands and returned job ID. +Closing the CLI/MCP process ends its session; a one-shot `exec render.start` is not a +durable background service. In a browser, leave the app running so frames can pump work. + +Validation evidence and responsiveness measurements are recorded in the R4a issue/PR; +passing native tests or a wasm compile does not establish browser visual performance. diff --git a/docs/render-opaque-pbr.md b/docs/render-opaque-pbr.md new file mode 100644 index 00000000..5b9930b1 --- /dev/null +++ b/docs/render-opaque-pbr.md @@ -0,0 +1,88 @@ +# Opaque product surfaces + +This is the first integrated consumer slice of #1004, under #813/#620. It uses the +existing Render workspace, product geometry, appearance assignment and PNG job host. +There is no second renderer or external material dataset. + +## Apply and preserve a response + +```json +{"command":"appearance.assign","params":{"bodies":["Specimen"],"color":"#b47846","pbr":{"metallic":1,"roughness":0.3,"dielectric_ior":1.5}}} +``` + +The Appearance editor has an **Opaque PBR** checkbox and separate Metallic, +Roughness and Dielectric IOR fields. Enabling it explicitly sets opacity to one. +View the response in Render or export a product PNG; Design retains its simple +color preview. Apply/OK, swatch reuse and component/face targeting use the existing +assignment command. A later scalar face override replaces a body's PBR response +on that face; it does not inherit the optical response implicitly. + +Parameters are finite and bounded: metallic [0,1], perceptual roughness [0.045,1], +relative dielectric IOR [1,3]. Unknown optical fields and nonnumeric/out-of-range +opacity refuse before changing the design. PBR requires opacity exactly one. +IOR controls the nonmetal fraction's reflection; it does not enable refraction. +Omitting `pbr` or supplying null in an appearance assignment selects the previous +scalar response. Assignment replaces a look; it is not a partial parameter patch. + +`material.create/edit` accept the same optional `pbr` block alongside a default +appearance/color. A density-only edit retains the entire existing look. Visual +response changes do not modify density, geometry, placement or engineering axes. +No grade-specific optical or mechanical values are inferred from a name or color. + +Format **21** saves `pbr` inside existing body/face/component and material-default +looks. Missing fields retain scalar shading. Format-20 render setups and old looks +upgrade without reinterpretation; older readers refuse newly saved files instead +of dropping their optical response. Undo/redo, copying and cold reopening use the +existing design-owned values, with no external file dependency. + +The current scalar catalog v1 cannot represent PBR. Conversion/export refuses a +PBR look or physical-material default instead of silently dropping its parameters. +In-design swatches retain and reuse the response. A richer catalog version remains +a separate integration step. + +## Actual consumers and color + +The installed integrated `gpu-viewport` and `cpu-product` image hosts advertise the +`pbr` feature. Requests require it when the captured product scene contains PBR. +The static legacy `cpu-raster` descriptor retains its previous capabilities. +No host gains HDRI, refraction, progressive/final rendering or EXR support. + +Look colors are decoded from sRGB8 to linear sRGB once in the shared document/scene +adapter. PBR GPU vertices carry linear reflectance and optical parameters. The CPU +rasterizer interpolates world position and normals with perspective correction; +the GPU uses the corresponding fragment interpolation. Both use the original +[opaque BRDF](render-surface.md) and three fixed neutral directional lights in Z-up +world coordinates. They sum BRDF × relative incident radiance × light cosine, +apply per-channel Reinhard preview tone mapping, then encode exactly once. +Linear and sRGB GPU attachments have distinct output transfer paths. + +This fixed studio has no cast shadows, environment illumination, indirect bounces, +emission, anisotropy, coating, absorption or transmission. Lighting magnitudes are +relative preview values, not calibrated lux or spectral data. The single-scattering +model can darken rough metals. Transparent legacy looks retain approximate sorted +alpha blending, including when seen in front of an opaque PBR surface; that is not +glass optics. Nondefault exposure/environment controls remain explicitly unsupported. + +Scene uploads keep the 128 MiB bound, accounting for the larger PBR vertices. +CPU face-map admission accounts for retained optical descriptors. Existing immutable +capture, ownership, cancellation, stale-result rejection and PNG export policies +remain in effect. Invalid/nonfinite geometry or zero input normals refuse. + +## Validation and original example + +`examples/render-opaque-studio.json` builds four original spheres and a base, +varying metallic fraction and roughness independently while keeping base color. +Its values are an illustrative optical study, not engineering data for real grades. + +The product offscreen test uses the actual installed callback/pipelines on Vulkan, +including both linear and sRGB attachments. For four metal/roughness combinations, +each comparison checks 12,454 interior pixels against CPU PNG output; observed mean +channel error is below 0.2/255, with no pixels beyond the 12/255 outlier threshold. +Silhouettes are excluded because CPU images use 2x supersampling and that test's GPU +target uses 1x. This is a scoped parity result, not browser or universal GPU certification. + +Command tests check unchanged geometry/mass, atomic refusal, undo/redo, cold reopen, +physical default/face precedence, retained scalar format-20 looks upgraded to 21 and explicit v1 +catalog refusal. A clicked editor regression checks one transaction and reopening +the same selected body. Native media and exact current-head gate results belong +in the PR. wasm compilation and actual browser interaction are separate evidence. diff --git a/docs/render-settings.md b/docs/render-settings.md index e33fd7f9..641c0b31 100644 --- a/docs/render-settings.md +++ b/docs/render-settings.md @@ -2,8 +2,10 @@ Named setups save common render intent in the document. They do not start a render, apply the navigation camera or make a currently unavailable backend available. -The Render workspace, scene extraction, assets, PBR materials and jobs are later slices -of [#620](https://github.com/bherbruck/solidcraft/issues/620). +The integrated workspace and jobs consume these settings through their own capability +checks. [Opaque PBR](render-opaque-pbr.md) is an opt-in appearance response; texture +and environment assets remain follow-ups of [#620](https://github.com/bherbruck/solidcraft/issues/620); +see [the combined integration boundary](render-integration.md). ## Commands diff --git a/docs/render-workspace.md b/docs/render-workspace.md new file mode 100644 index 00000000..983a010f --- /dev/null +++ b/docs/render-workspace.md @@ -0,0 +1,95 @@ +# Integrated Render workspace (R3) + +R4a builds on this viewport contract with a separately advertised CPU product-image +executor and Check/Start/Cancel/Export controls. See [render jobs](render-jobs.md) for +the asynchronous protocol, image limits, captured revisions and PNG export. The legacy +`ui.render` snapshot remains a separate route. + +Choose **Render** in the workspace switcher after finishing a modelling operation. +The current integrated GPU renderer shows a separate product scene. Return to Design +restores its camera, projection preference and existing viewport drawing policy. +No second engine is installed or selectable. + +## Product scene + +`engine::product_scene::extract` consumes authoritative evaluated geometry, reusing +the document's world/occurrence placement and mesh Arcs. Body, component, material, +face and imported appearances retain their existing precedence and opacity. Source +geometry is never transformed a second time or altered to serve a renderer. + +The shared L1 `ProductScene`/`ProductMesh` contract contains world-space meshes, +resolved sRGB colours, opacity and face appearance overrides. Face indices are the +existing mesh-to-B-rep mapping, resolved through document appearance references; +they are not new persistent names or triangle-derived face identities. + +There are no grid/axis/edge lines, sketches, construction objects, selection or hover +colours, section cuts, analysis shading, active-component fading, canvases or decals +in this initial product policy. Browser visibility still controls which bodies appear. +The existing Design viewport and CPU snapshot routes keep their current policies. + +Extraction refuses a stale live-drag model. The explicit model-reading `render.scene {}` +command follows the normal engine evaluation boundary, then returns bounded metadata: +body names, triangle counts, resolved colours/opacity, face-override counts and bounds. +It does not return mesh payloads, change the document or add an undo step. Product +extraction admits at most 16,384 visible bodies. + +## Settings and camera + +The right panel stages a named setup's background and camera changes. **Save Setup** +records the current existing `Camera` snapshot through `render.settings.set`, with +one undo step; the other R2 fields retain their values. **Reload Saved Settings** +discards staged settings. Draft settings are separate for each open document. + +**Restore Saved Camera** runs `render.settings.apply {name}`, preserving target, +distance, yaw, pitch, roll, FOV and up axis as view state. It neither edits the document +nor settles a stale model. The UI honours its up axis in Render; the Design camera +is restored when returning. Drag orbits, middle-drag pans, scrolling zooms, and +**Fit Product** frames visible product bodies without including sketches or selection. + +Preview drafts do not make the document dirty until saved. Unknown backend preferences +and unsupported modes remain saved intent: the strict resolver reports the unsupported +viewport request. **Use Integrated Preview** is an explicit draft change, never an +automatic fallback. There is no final-mode or second-engine selector in this slice. +Saved exposure/environment/output fields are not newly applied to shading or export. + +## Host capabilities and resources + +`render.backends` always retains the built-in `cpu-raster` preview/PNG image executor. +Only an installed UI GPU host adds `gpu-viewport`, advertising **Preview / Viewport** +with no image formats or HDRI/refraction/progressive features. The installed +[opaque PBR consumer](render-opaque-pbr.md) adds the `pbr` feature. The host reports +effective limits from its created device and depth attachment: at most 4096 pixels +per dimension and 4,194,304 pixels, within the 256 MiB pixel scratch admission policy. +The estimate includes the selected MSAA colour/depth cost; it excludes scene geometry, +driver overhead and cached source meshes. It is not a measurement of total GPU memory. + +Pure capability metadata lives in the session/host, outside persistence and history, +surviving document replacement, scratch sessions and document-tab switches. No wgpu +types enter the engine or document crates. A host without an installed GPU/depth buffer +reports that product viewport preview is unavailable, rather than silently using the +opaque CPU snapshot path to approximate transparent appearances. + +GPU product batches are separate from Design batches while sharing the installed +device and pipelines. Model/visibility/appearance changes update the product scene; +camera movement updates uniforms without rebuilding vertices. Selection and section +state do not change the product buffers. Vertex uploads are bounded to 128 MiB and +checked before allocation; malformed/non-finite triangles and invalid opacity fail. +Reinstalling the host invalidates product uploads. This is not a general device-loss, +render-job or cancellation protocol; those ownership mechanisms belong to R4. + +Transparency uses the existing raster far/near alpha-blending approximation, without +refraction or HDR illumination. Opaque PBR uses a separate surface batch in the +same installed renderer. R3 itself added no export job; R4a supplies CPU product +PNG jobs. There is no result gallery or Bevy dependency. Saved setups were added +in format 21, alongside opt-in optical parameters on existing looks. +`ui.render` remains the existing Design CPU snapshot API; `ui.screenshot` captures +the actual integrated Render workspace. Product-image export uses `render.start` +and `render.export`, rather than the Design snapshot API. + +## Reproduce + +Run the original procedural `examples/render-studio.json` through the engine/CLI and +open its saved design in the app. Select Render, adjust its background or camera and +save a setup. The fixture uses original boxes/cylinders and simple colour/opacity +assignments; no commercial CAD files, artwork or materials are used. Actual Windows +Vulkan/MSAA evidence is attached to [#673](https://github.com/bherbruck/solidcraft/issues/673). diff --git a/examples/render-opaque-studio.json b/examples/render-opaque-studio.json new file mode 100644 index 00000000..895c5a75 --- /dev/null +++ b/examples/render-opaque-studio.json @@ -0,0 +1,15 @@ +{ + "commands": [ + {"command":"file.new","params":{"name":"Original opaque surface study"}}, + {"command":"solid.sphere","params":{"body_name":"Nonmetal smooth","radius":10,"center":[-36,0,12]}}, + {"command":"solid.sphere","params":{"body_name":"Nonmetal rough","radius":10,"center":[-12,0,12]}}, + {"command":"solid.sphere","params":{"body_name":"Metal smooth","radius":10,"center":[12,0,12]}}, + {"command":"solid.sphere","params":{"body_name":"Metal rough","radius":10,"center":[36,0,12]}}, + {"command":"appearance.assign","params":{"bodies":["Nonmetal smooth"],"appearance":"Original nonmetal smooth","color":"#b47846","pbr":{"metallic":0,"roughness":0.2,"dielectric_ior":1.5}}}, + {"command":"appearance.assign","params":{"bodies":["Nonmetal rough"],"appearance":"Original nonmetal rough","color":"#b47846","pbr":{"metallic":0,"roughness":0.65,"dielectric_ior":1.5}}}, + {"command":"appearance.assign","params":{"bodies":["Metal smooth"],"appearance":"Original metal smooth","color":"#b47846","pbr":{"metallic":1,"roughness":0.2,"dielectric_ior":1.5}}}, + {"command":"appearance.assign","params":{"bodies":["Metal rough"],"appearance":"Original metal rough","color":"#b47846","pbr":{"metallic":1,"roughness":0.65,"dielectric_ior":1.5}}}, + {"command":"solid.box","params":{"body_name":"Study base","corner":[-49,-14,0],"length":98,"width":28,"height":2}}, + {"command":"appearance.assign","params":{"bodies":["Study base"],"color":"#ccd0d4"}} + ] +} diff --git a/examples/render-studio.json b/examples/render-studio.json new file mode 100644 index 00000000..1ca1ba7e --- /dev/null +++ b/examples/render-studio.json @@ -0,0 +1,344 @@ +{ + "commands": [ + { + "command": "file.new", + "params": { + "name": "Render studio enclosure" + } + }, + { + "command": "component.create", + "params": { + "name": "Tray" + } + }, + { + "command": "solid.box", + "params": { + "body_name": "Base", + "corner": [ + -32, + -22, + 0 + ], + "length": 64, + "height": 4, + "width": 44 + } + }, + { + "command": "solid.box", + "params": { + "body_name": "LeftWall", + "corner": [ + -32, + -22, + 4 + ], + "length": 3, + "height": 19, + "width": 44 + } + }, + { + "command": "solid.box", + "params": { + "body_name": "RightWall", + "corner": [ + 29, + -22, + 4 + ], + "length": 3, + "height": 19, + "width": 44 + } + }, + { + "command": "solid.box", + "params": { + "body_name": "FrontWall", + "corner": [ + -29, + -22, + 4 + ], + "length": 58, + "height": 19, + "width": 3 + } + }, + { + "command": "solid.box", + "params": { + "body_name": "BackWall", + "corner": [ + -29, + 19, + 4 + ], + "length": 58, + "height": 19, + "width": 3 + } + }, + { + "command": "appearance.assign", + "params": { + "bodies": [ + "Base", + "LeftWall", + "RightWall", + "FrontWall", + "BackWall" + ], + "color": "#82939e" + } + }, + { + "command": "component.activate", + "params": { + "component": 0 + } + }, + { + "command": "component.create", + "params": { + "name": "Insert" + } + }, + { + "command": "solid.box", + "params": { + "body_name": "Insert", + "corner": [ + -24, + -14, + 4 + ], + "length": 48, + "height": 10, + "width": 28 + } + }, + { + "command": "appearance.assign", + "params": { + "bodies": [ + "Insert" + ], + "color": "#199d8e" + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Pad1", + "base": [ + -15, + -7, + 14 + ], + "radius": 4, + "height": 0.8 + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Pad2", + "base": [ + -15, + 7, + 14 + ], + "radius": 4, + "height": 0.8 + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Pad3", + "base": [ + 0, + -7, + 14 + ], + "radius": 4, + "height": 0.8 + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Pad4", + "base": [ + 0, + 7, + 14 + ], + "radius": 4, + "height": 0.8 + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Pad5", + "base": [ + 15, + -7, + 14 + ], + "radius": 4, + "height": 0.8 + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Pad6", + "base": [ + 15, + 7, + 14 + ], + "radius": 4, + "height": 0.8 + } + }, + { + "command": "appearance.assign", + "params": { + "bodies": [ + "Pad1", + "Pad2", + "Pad3", + "Pad4", + "Pad5", + "Pad6" + ], + "color": "#124c48" + } + }, + { + "command": "component.activate", + "params": { + "component": 0 + } + }, + { + "command": "component.create", + "params": { + "name": "Fasteners" + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Post1", + "base": [ + -27, + -17, + 4 + ], + "radius": 2, + "height": 21 + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Post2", + "base": [ + -27, + 17, + 4 + ], + "radius": 2, + "height": 21 + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Post3", + "base": [ + 27, + -17, + 4 + ], + "radius": 2, + "height": 21 + } + }, + { + "command": "solid.cylinder", + "params": { + "body_name": "Post4", + "base": [ + 27, + 17, + 4 + ], + "radius": 2, + "height": 21 + } + }, + { + "command": "appearance.assign", + "params": { + "bodies": [ + "Post1", + "Post2", + "Post3", + "Post4" + ], + "color": "#ced6dc" + } + }, + { + "command": "component.activate", + "params": { + "component": 0 + } + }, + { + "command": "component.create", + "params": { + "name": "Lid" + } + }, + { + "command": "solid.box", + "params": { + "body_name": "ClearLid", + "corner": [ + -32, + -22, + 23 + ], + "length": 64, + "height": 2, + "width": 44 + } + }, + { + "command": "appearance.assign", + "params": { + "bodies": [ + "ClearLid" + ], + "color": "#bad9e6", + "opacity": 0.25 + } + }, + { + "command": "component.activate", + "params": { + "component": 0 + } + } + ] +}