diff --git a/.gitignore b/.gitignore index 6c985b5..a2b4696 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ node_modules/ # The loader JS is hand-authored and committed; the .js and .wasm are generated. /public/renderer_worker.js /public/renderer_worker_bg.wasm +# Locally-copied/generated OpenCascade.js artifacts are measured, not committed. +# The committed worker harness documents expected filenames. +/public/vendor/opencascade/ +/tools/renderer-step/build/ +**.log # Local databases (SQLite projection + Hydrant fjall store). /data/ /hydrant.db/ diff --git a/Cargo.lock b/Cargo.lock index 7646e38..365442f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4256,6 +4256,7 @@ dependencies = [ "jacquard-oauth", "jose-jwk", "miette", + "n0-future", "regex", "regex-lite", "reqwest", @@ -4566,6 +4567,7 @@ dependencies = [ "k256", "miette", "mini-moka-wasm", + "n0-future", "p256", "p384", "rand 0.8.6", @@ -6269,6 +6271,7 @@ dependencies = [ "axum", "axum-extra", "base64 0.22.1", + "bytes", "chrono", "console_error_panic_hook", "dioxus", diff --git a/Cargo.toml b/Cargo.toml index 5b35bcf..eb86232 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "crates/*"] resolver = "3" [workspace.dependencies] -jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky"] } +jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky", "streaming"] } jacquard-axum = { path = "../jacquard/crates/jacquard-axum" } jacquard-common = { path = "../jacquard/crates/jacquard-common" } jacquard-derive = { path = "../jacquard/crates/jacquard-derive" } @@ -122,6 +122,7 @@ zip = { version = "2", default-features = false, optional = true } # aspectRatio; ULID for opaque, sortable draft ids. Both server-only. imagesize = { version = "0.13", optional = true } ulid = { version = "1", optional = true } +bytes = { version = "1.12.0", features = ["serde"] } [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] @@ -144,6 +145,7 @@ http-body-util = "0.1" imagesize = "0.13" jacquard-axum = { workspace = true } keyring = "3" +serde_json = "1.0" ulid = "1" libsqlite3-sys = { version = "0.37", features = ["bundled"] } rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"] } @@ -174,11 +176,14 @@ debug = false lto = "fat" codegen-units = 1 strip = "symbols" +panic = "abort" + [profile.worker-release] inherits = "release" -opt-level = 3 +opt-level = "z" lto = "fat" +debug = false codegen-units = 1 strip = "symbols" panic = "abort" diff --git a/assets/styling/viewer.css b/assets/styling/viewer.css index 2b3a171..8e74284 100644 --- a/assets/styling/viewer.css +++ b/assets/styling/viewer.css @@ -74,6 +74,44 @@ color: var(--color-primary); } +.viewer-demo-button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + outline-offset: 1px; +} + +.viewer-demo-button:disabled { + cursor: wait; + opacity: 0.58; +} + +.viewer-step-conversion-button { + border-color: var(--color-action); + color: var(--color-tertiary); +} + +.viewer-step-conversion-panel { + border: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)); + padding: 0.75rem; +} + +.viewer-step-conversion-panel p { + margin: 0; +} + +.viewer-step-conversion-label { + margin-bottom: 0.25rem; + color: var(--color-primary); + font-family: var(--font-mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.viewer-step-conversion-panel[data-step-status="STEP blocked"] { + border-color: var(--color-error); +} + .model-viewer-boundary { position: relative; overflow: hidden; diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_defs.json b/crates/polymodel-api/lexicons/space_polymodel_library_defs.json index 0365855..f1a554b 100644 --- a/crates/polymodel-api/lexicons/space_polymodel_library_defs.json +++ b/crates/polymodel-api/lexicons/space_polymodel_library_defs.json @@ -200,7 +200,9 @@ "model/stl", "model/3mf", "model/obj", - "application/octet-stream" + "application/octet-stream", + "model/step", + "application/step" ], "maxSize": 104857600 }, @@ -262,14 +264,15 @@ "maxLength": 4096 }, "digest": { - "type": "string", - "description": "hex digest of the fully reassembled file (sha256 by convention)" + "type": "bytes", + "description": "sha256 digest of the fully reassembled file" }, "mimeType": { "type": "string" }, "size": { - "type": "integer" + "type": "integer", + "description": "size in bytes of the fully reassembled file" } } }, diff --git a/crates/polymodel-api/src/space_polymodel/library.rs b/crates/polymodel-api/src/space_polymodel/library.rs index d741a14..0ae0207 100644 --- a/crates/polymodel-api/src/space_polymodel/library.rs +++ b/crates/polymodel-api/src/space_polymodel/library.rs @@ -26,6 +26,7 @@ use alloc::collections::BTreeMap; #[allow(unused_imports)] use core::marker::PhantomData; use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::bytes::Bytes; #[allow(unused_imports)] use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; @@ -191,10 +192,12 @@ pub struct FeedView { #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct File { pub chunks: Vec>, - ///hex digest of the fully reassembled file (sha256 by convention) + ///sha256 digest of the fully reassembled file #[serde(skip_serializing_if = "Option::is_none")] - pub digest: Option, + #[serde(default, with = "jacquard_common::opt_serde_bytes_helper")] + pub digest: Option, pub mime_type: S, + ///size in bytes of the fully reassembled file pub size: i64, #[serde(flatten, default, skip_serializing_if = "Option::is_none")] pub extra_data: Option>>, @@ -479,6 +482,8 @@ impl LexiconSchema for Chunk { "model/3mf", "model/obj", "application/octet-stream", + "model/step", + "application/step", ]; let matched = accepted .iter() @@ -498,7 +503,8 @@ impl LexiconSchema for Chunk { path: ValidationPath::from_field("blob"), accepted: vec![ "model/stl".to_string(), "model/3mf".to_string(), "model/obj" - .to_string(), "application/octet-stream".to_string() + .to_string(), "application/octet-stream".to_string(), + "model/step".to_string(), "application/step".to_string() ], actual: mime.to_string(), }); @@ -1515,14 +1521,7 @@ fn lexicon_doc_space_polymodel_library_defs() -> LexiconDoc<'static> { ); map.insert( SmolStr::new_static("digest"), - LexObjectProperty::String(LexString { - description: Some( - CowStr::new_static( - "hex digest of the fully reassembled file (sha256 by convention)", - ), - ), - ..Default::default() - }), + LexObjectProperty::Bytes(LexBytes { ..Default::default() }), ); map.insert( SmolStr::new_static("mimeType"), @@ -3310,7 +3309,7 @@ pub mod file_state { /// Builder for constructing an instance of this type. pub struct FileBuilder { _state: PhantomData St>, - _fields: (Option>>, Option, Option, Option), + _fields: (Option>>, Option, Option, Option), _type: PhantomData S>, } @@ -3371,12 +3370,12 @@ where impl FileBuilder { /// Set the `digest` field (optional) - pub fn digest(mut self, value: impl Into>) -> Self { + pub fn digest(mut self, value: impl Into>) -> Self { self._fields.1 = value.into(); self } /// Set the `digest` field to an Option value (optional) - pub fn maybe_digest(mut self, value: Option) -> Self { + pub fn maybe_digest(mut self, value: Option) -> Self { self._fields.1 = value; self } diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index ef5d423..5156f21 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -171,22 +171,23 @@ impl WorkerState { fn build_placeholder_mesh( context: &three_d::Context, ) -> three_d::Gm { + let mut mesh = three_d_asset::TriMesh { + positions: three_d_asset::Positions::F32(vec![ + three_d_asset::Vec3::new(0.0, 0.0, 0.18), + three_d_asset::Vec3::new(-0.16, -0.12, -0.08), + three_d_asset::Vec3::new(0.16, -0.12, -0.08), + three_d_asset::Vec3::new(0.0, 0.18, -0.08), + ]), + indices: three_d_asset::Indices::U32(vec![0, 1, 2, 0, 2, 3, 0, 3, 1, 1, 3, 2]), + normals: None, + tangents: None, + uvs: None, + colors: None, + }; + mesh.compute_normals(); + three_d::Gm::new( - three_d::Mesh::new( - context, - &three_d_asset::TriMesh { - positions: three_d_asset::Positions::F32(vec![ - three_d_asset::Vec3::new(0.0, 0.0, 0.0), - three_d_asset::Vec3::new(0.0, 0.0, 0.0), - three_d_asset::Vec3::new(0.0, 0.0, 0.0), - ]), - indices: three_d_asset::Indices::U32(vec![0, 1, 2]), - normals: None, - tangents: None, - uvs: None, - colors: None, - }, - ), + three_d::Mesh::new(context, &mesh), three_d::PhysicalMaterial { albedo: three_d::Srgba::new_opaque(116, 180, 255), roughness: 0.72, @@ -220,7 +221,7 @@ fn build_scene( ..Default::default() }, ); - let radius = fit.radius.max(1.0); + let radius = fit.radius.max(0.001); let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); let viewport = three_d::Viewport::new_at_origo(pixel_width, pixel_height); @@ -230,10 +231,10 @@ fn build_scene( target, three_d::vec3(0.0, 0.0, 1.0), three_d::degrees(45.0), - (radius / 100.0).max(0.1), - radius * 20.0, + (radius / 1000.0).max(0.000001), + radius * 100.0, ); - let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); + let control = three_d::OrbitControl::new(target, radius * 0.01, radius * 50.0); (mesh, camera, control) } @@ -770,7 +771,9 @@ pub fn entry() { let bytes = array.to_vec(); match deserialize_command(&bytes) { Ok(cmd) => handle_command(&state_for_msg, cmd), - Err(e) => tracing::error!("failed to deserialize command: {e}"), + Err(e) => { + tracing::error!("failed to deserialize command: {e}"); + } } } }) as Box); diff --git a/justfile b/justfile index f35442c..63b7603 100644 --- a/justfile +++ b/justfile @@ -29,7 +29,6 @@ check: # Required when regenerating the `.sqlx` cache, and re-run after editing migrations. migrate: mkdir -p ./data - -cargo sqlx database create --database-url '{{ db-url }}' cargo sqlx migrate run --source migrations --database-url '{{ db-url }}' # Regenerate the committed `.sqlx` offline query cache after changing SQL. @@ -61,12 +60,17 @@ build-renderer-worker: RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p polymodel-renderer-worker --bin renderer_worker --target wasm32-unknown-unknown --profile worker-release wasm-bindgen target/wasm32-unknown-unknown/worker-release/renderer_worker.wasm --target web --out-dir public --no-typescript @if command -v wasm-opt &>/dev/null; then wasm-opt public/renderer_worker_bg.wasm -O4 --enable-bulk-memory --enable-simd --enable-nontrapping-float-to-int --enable-sign-ext -o public/renderer_worker_bg.wasm; else echo "wasm-opt not found, skipping optimization"; fi + +# Build and stage the single-threaded OpenCascade.js STEP conversion artifacts. +build-opencascade-step: + node tools/renderer-step/build-and-stage-opencascade.mjs + # Build the Dioxus app for web (includes renderer worker build). build-web: build-renderer-worker dx build --platform web # Build the Dioxus app for web (release, optimized). Strips debug symbols and runs wasm-opt. -build-web-release: build-renderer-worker +build-web-release: build-renderer-worker build-opencascade-step #!/usr/bin/env bash set -e dx build --platform web --release --debug-symbols=false diff --git a/lexicons/library/defs.json b/lexicons/library/defs.json index 6f2d915..ef9db53 100644 --- a/lexicons/library/defs.json +++ b/lexicons/library/defs.json @@ -4,10 +4,7 @@ "defs": { "actor": { "type": "object", - "required": [ - "did", - "handle" - ], + "required": ["did", "handle"], "properties": { "did": { "type": "string", @@ -78,19 +75,12 @@ "type": "string" } }, - "required": [ - "x", - "y", - "z" - ] + "required": ["x", "y", "z"] }, "aspectRatio": { "type": "object", "description": "Width:height aspect ratio of an image, for layout before the blob loads.", - "required": [ - "width", - "height" - ], + "required": ["width", "height"], "properties": { "width": { "type": "integer" @@ -103,19 +93,11 @@ "image": { "type": "object", "description": "An authored image: a blob plus alt text and aspect ratio. Stored on records; the app view resolves it to #imageView.", - "required": [ - "image", - "alt", - "aspectRatio" - ], + "required": ["image", "alt", "aspectRatio"], "properties": { "image": { "type": "blob", - "accept": [ - "image/png", - "image/jpeg", - "image/webp" - ], + "accept": ["image/png", "image/jpeg", "image/webp"], "maxSize": 5242880 }, "alt": { @@ -133,10 +115,7 @@ "imageView": { "type": "object", "description": "A resolved image view: CDN URLs for thumbnail and full size plus aspect ratio. Currently resolved via Bluesky's CDN by the app view.", - "required": [ - "thumb", - "fullsize" - ], + "required": ["thumb", "fullsize"], "properties": { "alt": { "type": "string" @@ -165,11 +144,12 @@ "type": "string" }, "size": { - "type": "integer" + "type": "integer", + "description": "size in bytes of the fully reassembled file" }, "digest": { - "type": "string", - "description": "hex digest of the fully reassembled file (sha256 by convention)" + "type": "bytes", + "description": "sha256 digest of the fully reassembled file" }, "chunks": { "type": "array", @@ -180,11 +160,7 @@ "maxLength": 4096 } }, - "required": [ - "mimeType", - "size", - "chunks" - ] + "required": ["mimeType", "size", "chunks"] }, "chunk": { "type": "object", @@ -195,7 +171,9 @@ "model/stl", "model/3mf", "model/obj", - "application/octet-stream" + "application/octet-stream", + "model/step", + "application/step" ], "maxSize": 104857600 }, @@ -208,11 +186,7 @@ "minimum": 0 } }, - "required": [ - "blob", - "offset", - "size" - ] + "required": ["blob", "offset", "size"] }, "thingView": { "type": "object", @@ -384,16 +358,7 @@ }, "modelView": { "type": "object", - "required": [ - "uri", - "cid", - "author", - "record", - "indexedAt", - "name", - "partCount", - "viewer" - ], + "required": ["uri", "cid", "author", "record", "indexedAt", "name", "partCount", "viewer"], "properties": { "uri": { "type": "string", @@ -444,16 +409,7 @@ }, "partView": { "type": "object", - "required": [ - "uri", - "cid", - "author", - "record", - "indexedAt", - "name", - "file", - "viewer" - ], + "required": ["uri", "cid", "author", "record", "indexedAt", "name", "file", "viewer"], "properties": { "uri": { "type": "string", @@ -504,9 +460,7 @@ "ref": "#thingViewBasic" } }, - "required": [ - "thing" - ] + "required": ["thing"] }, "feedView": { "type": "object", @@ -522,20 +476,12 @@ } } }, - "required": [ - "items" - ] + "required": ["items"] }, "actorViewDetailed": { "type": "object", "description": "Detailed hydrated actor/profile view for profile surfaces, modeled after app.bsky.actor profile view splits.", - "required": [ - "did", - "handle", - "record", - "indexedAt", - "viewer" - ], + "required": ["did", "handle", "record", "indexedAt", "viewer"], "properties": { "did": { "type": "string", diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 764e7e3..34d0796 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -222,7 +222,7 @@ CREATE INDEX idx_oauth_auth_req_did ON oauth_auth_requests(account_did); CREATE TABLE upload_staging ( owner_did TEXT NOT NULL, upload_id TEXT NOT NULL, - sha256 TEXT NOT NULL, + sha256 BLOB NOT NULL, mime_type TEXT NOT NULL, size INTEGER NOT NULL, filename TEXT, diff --git a/public/renderer_worker_loader.js b/public/renderer_worker_loader.js index 13aaad6..63ae89f 100644 --- a/public/renderer_worker_loader.js +++ b/public/renderer_worker_loader.js @@ -3,5 +3,5 @@ // function that must be called with the WASM module path before the worker // can receive messages. This loader does that and is the entry point for the // module worker spawned by WorkerBridge. -import init from './renderer_worker.js'; -await init({ module_or_path: './renderer_worker_bg.wasm' }); +const { default: init } = await import(new URL('/renderer_worker.js', self.location.origin)); +await init({ module_or_path: '/renderer_worker_bg.wasm' }); diff --git a/public/step_worker.js b/public/step_worker.js new file mode 100644 index 0000000..95e236e --- /dev/null +++ b/public/step_worker.js @@ -0,0 +1,224 @@ +const DEFAULT_STEP_URL = '/models/EInk_click_v101.step'; +const DEFAULT_LIN_DEFLECTION = 0.1; +const DEFAULT_ANG_DEFLECTION = 0.1; + +let openCascadePromise; + +self.postMessage({ kind: 'ready' }); + +self.onmessage = async (event) => { + const msg = event.data || {}; + if (msg.command !== 'convert-step') { + return; + } + + const timings = []; + const startedAt = performance.now(); + + try { + const stepUrl = msg.stepUrl || DEFAULT_STEP_URL; + const linDeflection = Number(msg.linDeflection ?? DEFAULT_LIN_DEFLECTION); + const angDeflection = Number(msg.angDeflection ?? DEFAULT_ANG_DEFLECTION); + + mark(timings, 'fetch:start'); + const stepBytes = new Uint8Array(await (await fetch(stepUrl)).arrayBuffer()); + mark(timings, 'fetch:end'); + + mark(timings, 'init:start'); + const oc = await loadOpenCascade(); + validateRequiredBindings(oc); + mark(timings, 'init:end'); + + mark(timings, 'read:start'); + writeFile(oc, '/model.step', stepBytes); + const doc = readStepIntoDocument(oc, '/model.step'); + mark(timings, 'read:end'); + + mark(timings, 'tessellate:start'); + const shape = firstFreeShape(oc, doc); + if (oc.BRepTools?.Clean && shape) { + oc.BRepTools.Clean(shape, false); + } + if (shape) { + new oc.BRepMesh_IncrementalMesh_2(shape, linDeflection, false, angDeflection, false); + } + mark(timings, 'tessellate:end'); + + mark(timings, 'export:start'); + const glbBytes = exportDocumentToGlb(oc, doc, '/model.glb'); + mark(timings, 'export:end'); + + self.postMessage({ + kind: 'converted', + timings, + totalMs: performance.now() - startedAt, + stepBytes: stepBytes.byteLength, + glbBytes: glbBytes.byteLength, + buffer: glbBytes.buffer, + crossOriginIsolated: self.crossOriginIsolated === true, + hardwareConcurrency: navigator.hardwareConcurrency || null, + }, [glbBytes.buffer]); + } catch (error) { + self.postMessage({ + kind: 'error', + timings, + totalMs: performance.now() - startedAt, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + crossOriginIsolated: self.crossOriginIsolated === true, + hardwareConcurrency: navigator.hardwareConcurrency || null, + }); + } +}; + +async function loadOpenCascade() { + if (!openCascadePromise) { + openCascadePromise = (async () => { + const openCascadeUrl = new URL('/vendor/opencascade/opencascade.js', self.location.origin).href; + const module = await import(openCascadeUrl); + const init = module.default || module.initOpenCascade || module.init; + if (typeof init !== 'function') { + throw new Error('OpenCascade module did not export an init function'); + } + const options = { + locateFile(path) { + if (path.endsWith('.wasm')) { + return '/vendor/opencascade/opencascade.wasm'; + } + return `/vendor/opencascade/${path}`; + }, + }; + return init(options); + })(); + } + return openCascadePromise; +} + +function writeFile(oc, path, bytes) { + if (oc.FS.analyzePath(path).exists) { + oc.FS.unlink(path); + } + oc.FS.writeFile(path, bytes); +} + +function validateRequiredBindings(oc) { + const required = [ + ['STEPCAFControl_Reader_1', 'STEPCAFControl_Reader'], + ['TDocStd_Document'], + ['Handle_TDocStd_Document_2'], + ['TCollection_ExtendedString_1'], + ['XCAFDoc_DocumentTool'], + ['XCAFDoc_ShapeTool'], + ['TDF_LabelSequence_1'], + ['BRepMesh_IncrementalMesh_2', 'BRepMesh_IncrementalMesh'], + ['RWGltf_CafWriter'], + ['TCollection_AsciiString_2'], + ['TColStd_IndexedDataMapOfStringString_1'], + ]; + const missing = required + .filter((alternatives) => !alternatives.some((name) => oc[name] !== undefined)) + .map((alternatives) => alternatives.join(' or ')); + if (missing.length > 0) { + throw new Error(`OpenCascade.js build is missing required STEP→GLB bindings: ${missing.join(', ')}`); + } +} + +function readStepIntoDocument(oc, path) { + const reader = constructFirst(oc, ['STEPCAFControl_Reader_1', 'STEPCAFControl_Reader']); + const readStatus = reader.ReadFile(path); + if (readStatus !== undefined && isFailureStatus(oc, readStatus)) { + throw new Error(`STEP read failed with status ${readStatus}`); + } + + const doc = constructDocument(oc); + const handle = construct(oc, 'Handle_TDocStd_Document_2', doc); + const progress = optionalConstruct(oc, 'Message_ProgressRange_1'); + const transferred = progress + ? callFirst(reader, ['Transfer_1', 'Transfer'], [handle, progress]) + : callFirst(reader, ['Transfer_1', 'Transfer'], [handle]); + if (transferred === false) { + throw new Error('STEP transfer into XDE document failed'); + } + return doc; +} + +function constructDocument(oc) { + const name = construct(oc, 'TCollection_ExtendedString_1'); + return construct(oc, 'TDocStd_Document', name); +} + +function firstFreeShape(oc, doc) { + if (!oc.XCAFDoc_DocumentTool?.ShapeTool) { + return undefined; + } + const shapeToolHandle = oc.XCAFDoc_DocumentTool.ShapeTool(doc.Main()); + const shapeTool = shapeToolHandle?.get ? shapeToolHandle.get() : shapeToolHandle; + const labels = construct(oc, 'TDF_LabelSequence_1'); + shapeTool.GetFreeShapes(labels); + if (labels.Length && labels.Length() < 1) { + return undefined; + } + const label = labels.First(); + return callFirst(oc.XCAFDoc_ShapeTool, ['GetShape_2', 'GetShape'], [label]); +} + +function exportDocumentToGlb(oc, doc, outputPath) { + if (!oc.RWGltf_CafWriter) { + throw new Error('RWGltf_CafWriter is missing from this OpenCascade.js build'); + } + if (oc.FS.analyzePath(outputPath).exists) { + oc.FS.unlink(outputPath); + } + const writer = new oc.RWGltf_CafWriter(construct(oc, 'TCollection_AsciiString_2', outputPath), true); + const handle = construct(oc, 'Handle_TDocStd_Document_2', doc); + const metadata = construct(oc, 'TColStd_IndexedDataMapOfStringString_1'); + const progress = optionalConstruct(oc, 'Message_ProgressRange_1'); + const exported = progress + ? callFirst(writer, ['Perform_2', 'Perform'], [handle, metadata, progress]) + : callFirst(writer, ['Perform_2', 'Perform'], [handle, metadata]); + if (exported === false) { + throw new Error('RWGltf_CafWriter failed to export GLB'); + } + return oc.FS.readFile(outputPath, { encoding: 'binary' }); +} + +function construct(oc, name, ...args) { + const ctor = oc[name]; + if (typeof ctor !== 'function') { + throw new Error(`${name} is missing from this OpenCascade.js build`); + } + return new ctor(...args); +} + +function optionalConstruct(oc, name, ...args) { + return typeof oc[name] === 'function' ? new oc[name](...args) : undefined; +} + +function constructFirst(oc, names, ...args) { + for (const name of names) { + if (typeof oc[name] === 'function') { + return new oc[name](...args); + } + } + throw new Error(`${names.join(' or ')} is missing from this OpenCascade.js build`); +} + +function callFirst(target, names, args) { + for (const name of names) { + if (typeof target?.[name] === 'function') { + return target[name](...args); + } + } + throw new Error(`missing method ${names.join(' or ')}`); +} + +function isFailureStatus(oc, status) { + if (typeof oc.IFSelect_ReturnStatus?.IFSelect_RetDone === 'number') { + return status !== oc.IFSelect_ReturnStatus.IFSelect_RetDone; + } + return false; +} + +function mark(timings, label) { + timings.push({ label, ms: performance.now() }); +} diff --git a/public/step_worker_loader.js b/public/step_worker_loader.js new file mode 100644 index 0000000..f896db3 --- /dev/null +++ b/public/step_worker_loader.js @@ -0,0 +1,4 @@ +// This file is deliberately hand-authored and tiny. The heavy OpenCascade.js +// artifacts live under /vendor/opencascade/ and are not bundled into the +// Dioxus app or renderer worker. +import './step_worker.js'; diff --git a/src/appview/downloads.rs b/src/appview/downloads.rs index 1cfbdd6..965a97f 100644 --- a/src/appview/downloads.rs +++ b/src/appview/downloads.rs @@ -2,8 +2,10 @@ use axum::body::Body; use axum::extract::{Path, State}; use axum::http::{StatusCode, header}; use axum::response::Response; +use futures::TryStreamExt; +use jacquard::identity::resolver::IdentityResolver; use jacquard_common::types::string::{Cid, Did}; -use jacquard_common::xrpc::XrpcClient; +use jacquard_common::xrpc::XrpcExt; use polymodel_api::com_atproto::sync::get_blob::GetBlob; use polymodel_api::space_polymodel::library::File; use sha2::{Digest, Sha256}; @@ -46,36 +48,61 @@ pub(super) async fn download_part( } validate_file_manifest(&file)?; + let did_doc_response = state + .resolver + .resolve_did_doc(&did) + .await + .map_err(|e| internal(format!("DID document resolution failed: {e}")))?; + let did_doc = did_doc_response + .parse() + .map_err(|e| internal(format!("DID document parse failed: {e}")))?; + let pds_endpoint = did_doc + .pds_endpoint() + .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))? + .to_owned(); + let mut chunks = file.chunks; chunks.sort_by_key(|chunk| chunk.offset); let mut bytes = Vec::with_capacity(file.size as usize); for chunk in chunks { let cid: Cid = Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes()) .map_err(|e| internal(format!("part blob CID is invalid: {e}")))?; + let request = GetBlob { + did: did.clone(), + cid, + }; let response = state - .bsky - .send(GetBlob { - did: did.clone(), - cid, - }) + .resolver + .xrpc(pds_endpoint.borrow()) + .download(&request) + .await + .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?; + if !response.status().is_success() { + return Err(internal(format!( + "blob fetch from {pds_endpoint} failed with HTTP {}", + response.status() + ))); + } + let chunk_start = bytes.len(); + let (_, body) = response.into_parts(); + let mut stream = body.into_inner(); + while let Some(chunk_bytes) = stream + .try_next() .await - .map_err(|e| internal(format!("blob fetch failed: {e}")))?; - let output = response - .into_output() - .map_err(|e| internal(format!("blob response decode failed: {e}")))?; - if output.body.len() != chunk.size as usize { + .map_err(|e| internal(format!("blob stream from {pds_endpoint} failed: {e}")))? + { + bytes.extend_from_slice(&chunk_bytes); + } + if bytes.len() - chunk_start != chunk.size as usize { return Err(internal("blob chunk length did not match manifest")); } - bytes.extend_from_slice(&output.body); } if bytes.len() != file.size as usize { return Err(internal("downloaded file length did not match manifest")); } if let Some(digest) = file.digest.as_ref() { - let actual = hex_sha256(&bytes); - let digest: &str = digest.as_ref(); - let expected = digest.strip_prefix("sha256:").unwrap_or(digest); - if actual != expected { + let actual = Sha256::digest(&bytes); + if actual[..] != *digest.as_ref() { return Err(internal("downloaded file digest did not match manifest")); } } @@ -124,13 +151,6 @@ fn validate_file_manifest(file: &File) -> AppResult<()> { Ok(()) } -fn hex_sha256(bytes: &[u8]) -> String { - Sha256::digest(bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - fn download_filename(name: &str) -> String { let cleaned = name .chars() @@ -148,7 +168,7 @@ fn download_filename(name: &str) -> String { #[cfg(test)] mod tests { - use super::{download_filename, hex_sha256, validate_file_manifest}; + use super::{download_filename, validate_file_manifest}; use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::types::blob::{Blob, BlobRef, MimeType}; use jacquard_common::types::cid::CidLink; @@ -191,12 +211,4 @@ mod tests { assert!(validate_file_manifest(&file(vec![gapped], 6)).is_err()); assert!(validate_file_manifest(&file(vec![chunk], 101 * 1024 * 1024)).is_err()); } - - #[test] - fn hex_sha256_matches_known_digest() { - assert_eq!( - hex_sha256(b"solid cube"), - "d3a15aa3cd30cc79123d6a50d2809ed794a452e67fa857bbc7ac343cbfca9971" - ); - } } diff --git a/src/appview/writes.rs b/src/appview/writes.rs index c76de4b..c290abf 100644 --- a/src/appview/writes.rs +++ b/src/appview/writes.rs @@ -10,6 +10,7 @@ use axum::Json; use axum::body::Body; use axum::extract::State; use axum::http::{HeaderMap, header}; +use bytes::Bytes; use http_body_util::BodyExt; use jacquard::client::{Agent, AgentSession, AgentSessionExt}; use jacquard::identity::PublicResolver; @@ -122,12 +123,12 @@ pub(super) async fn stage_file( let file = File { chunks: staged.chunks, - digest: Some(SmolStr::from(staged.digest.clone())), + digest: Some(Bytes::copy_from_slice(&staged.digest)), mime_type: SmolStr::from(mime_type.clone()), size: staged.size as i64, extra_data: None, }; - let upload_id = format!("{}-{}", &staged.digest[..16], staged.size); + let upload_id = format!("{}-{}", hex_bytes(&staged.digest[..16]), staged.size); persist_upload( &state, &actor, @@ -1373,7 +1374,7 @@ async fn eager_delete( struct StreamedUpload { chunks: Vec, - digest: String, + digest: Vec, size: usize, } @@ -1435,11 +1436,7 @@ async fn upload_streamed_file( let offset = size - pending.len(); upload_one_chunk(agent, mime_type, &mut chunks, offset, pending).await?; } - let digest = hasher - .finalize() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); + let digest = hasher.finalize().to_vec(); Ok(StreamedUpload { chunks, digest, @@ -1489,7 +1486,7 @@ where struct UploadPersistence<'a> { upload_id: &'a str, - digest: &'a str, + digest: &'a [u8], mime_type: &'a str, size: usize, filename: Option<&'a str>, @@ -1663,9 +1660,12 @@ fn filename_header(headers: &HeaderMap) -> Option { } #[cfg(test)] -fn hex_sha256(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - digest.iter().map(|b| format!("{b:02x}")).collect() +fn sha256_bytes(bytes: &[u8]) -> Vec { + Sha256::digest(bytes).to_vec() +} + +fn hex_bytes(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() } fn agent_error(operation: &str, err: jacquard::client::AgentError) -> AppError { @@ -1864,10 +1864,10 @@ mod tests { let state = state().await; let actor = did(DID_A); let bytes = b"solid cube"; - let digest = hex_sha256(bytes); + let digest = sha256_bytes(bytes); let file = File { chunks: Vec::new(), - digest: Some(SmolStr::from(digest.clone())), + digest: Some(Bytes::copy_from_slice(&digest)), mime_type: SmolStr::from("model/stl"), size: bytes.len() as i64, extra_data: None, @@ -1889,7 +1889,10 @@ mod tests { .unwrap(); let loaded = load_upload_file(&state, &actor, "upload-1").await.unwrap(); - assert_eq!(loaded.digest.as_deref(), Some(digest.as_str())); + assert_eq!( + loaded.digest.as_ref().map(Bytes::as_ref), + Some(digest.as_slice()) + ); assert_eq!(loaded.mime_type.as_str(), "model/stl"); assert_eq!(loaded.size, bytes.len() as i64); assert!( diff --git a/src/indexing/sample_data.rs b/src/indexing/sample_data.rs index b307448..8407728 100644 --- a/src/indexing/sample_data.rs +++ b/src/indexing/sample_data.rs @@ -420,7 +420,6 @@ fn file_manifest(size: i64) -> Value { json!({ "mimeType": "model/stl", "size": size, - "digest": "sha256:sample-polymodel-fixture", "chunks": [{ "blob": blob("bafkreistlchunk", "model/stl", size), "offset": 0, "size": size }] }) } diff --git a/src/thing_detail.rs b/src/thing_detail.rs index db5685f..a8393e0 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -3,6 +3,7 @@ use jacquard::common::types::ident::AtIdentifier; use jacquard::common::types::recordkey::Rkey; use jacquard::common::types::string::{AtUri, Cid}; use jacquard::common::types::value::from_data; +use jacquard_common::deps::smol_str::{SmolStr, StrExt}; use jacquard_common::xrpc::XrpcClient; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use polymodel_api::space_polymodel::graph::{ @@ -10,12 +11,15 @@ use polymodel_api::space_polymodel::graph::{ delete_save::DeleteSave, }; use polymodel_api::space_polymodel::library; +use polymodel_renderer_protocol::MeshFormat; use crate::Route; use crate::client::PolymodelClient; use crate::profile::profile_href; use crate::session::SessionIdentity; -use crate::viewer::{AssetViewer, ViewerFallbackImage, demo_models}; +use crate::viewer::{ + AssetViewer, ViewerFallbackImage, ViewerMeshSource, ViewerSourceFormat, demo_models, +}; #[derive(Clone, Debug, PartialEq, Eq)] enum DetailState { @@ -91,6 +95,68 @@ fn part_download_href(uri: &AtUri) -> Option { )) } +fn viewer_source_for_part(part: &PartDetail) -> Option { + let url = part_download_href(&part.view.uri)?; + let name = clean_text(Some(part.view.name.as_ref())) + .unwrap_or("Untitled part") + .to_string(); + let format = part_viewer_format(part, &url, &name)?; + Some(ViewerMeshSource { + key: part.view.uri.as_str().to_string(), + name, + url, + format, + }) +} + +fn part_viewer_format(part: &PartDetail, url: &str, name: &str) -> Option { + [ + Some(part.view.file.mime_type.as_str()), + Some(url), + Some(name), + part.view.format.as_ref().map(|format| format.as_str()), + part.record.format.as_ref().map(|format| format.as_str()), + ] + .into_iter() + .flatten() + .find_map(viewer_format_candidate) +} + +fn viewer_format_candidate(candidate: &str) -> Option { + let value = candidate.trim().to_lowercase_smolstr(); + if matches!( + value.as_str(), + "step" | "stp" | "model/step" | "application/step" | "application/x-step" + ) || extension_is(&value, "step") + || extension_is(&value, "stp") + { + return Some(ViewerSourceFormat::Step); + } + if matches!(value.as_str(), "stl" | "model/stl" | "application/sla") { + return Some(ViewerSourceFormat::Renderer(MeshFormat::Stl)); + } + if matches!(value.as_str(), "obj" | "model/obj") { + return Some(ViewerSourceFormat::Renderer(MeshFormat::Obj)); + } + if matches!( + value.as_str(), + "gltf" | "glb" | "model/gltf+json" | "model/gltf-binary" + ) { + return Some(ViewerSourceFormat::Renderer(MeshFormat::Gltf)); + } + if matches!(value.as_str(), "3mf" | "model/3mf") { + return Some(ViewerSourceFormat::Renderer(MeshFormat::Threemf)); + } + MeshFormat::from_extension(value.as_str()).map(ViewerSourceFormat::Renderer) +} + +fn extension_is(value: &SmolStr, extension: &str) -> bool { + value + .as_str() + .rsplit_once('.') + .is_some_and(|(_, ext)| ext == extension) +} + #[cfg_attr(not(feature = "server"), allow(dead_code))] fn thing_uri_from_route(repo: &str, rkey: &str) -> Result { let repo: AtIdentifier = AtIdentifier::new_owned(repo) @@ -309,6 +375,10 @@ fn DetailPopulated( .as_ref() .and_then(|selected| selected.parts.first()) .and_then(|part| part_download_href(&part.view.uri)); + let viewer_source = selected_model + .as_ref() + .and_then(|selected| selected.parts.first()) + .and_then(viewer_source_for_part); let instructions = thing_record.instructions; rsx! { @@ -335,7 +405,11 @@ fn DetailPopulated( section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing overview", div { class: "thing-detail-viewer", - AssetViewer { initial_model: demo_models()[0], fallback: media.map(|media| ViewerFallbackImage { url: media.url, alt: media.alt }) } + AssetViewer { + initial_model: demo_models()[0], + fallback: media.map(|media| ViewerFallbackImage { url: media.url, alt: media.alt }), + source: viewer_source, + } } div { class: "thing-detail-summary", diff --git a/src/viewer.rs b/src/viewer.rs index baf4c24..152fc20 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -41,6 +41,59 @@ pub struct ViewerFallbackImage { pub alt: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ViewerSourceFormat { + Renderer(MeshFormat), + Step, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ViewerMeshSource { + pub key: String, + pub name: String, + pub url: String, + pub format: ViewerSourceFormat, +} + +#[derive(Clone, Debug, PartialEq)] +struct RenderSource { + key: String, + format: MeshFormat, + primary_url: String, +} + +#[derive(Clone, Debug, PartialEq)] +struct StepConversionReport { + status: StepConversionStatus, + details: String, +} + +#[derive(Clone, Debug, PartialEq)] +enum StepConversionStatus { + Idle, + Converting, + Converted, + Failed, +} + +impl StepConversionReport { + fn idle() -> Self { + Self { + status: StepConversionStatus::Idle, + details: "STEP conversion has not been requested.".into(), + } + } + + fn label(&self) -> &'static str { + match self.status { + StepConversionStatus::Idle => "STEP idle", + StepConversionStatus::Converting => "STEP converting", + StepConversionStatus::Converted => "STEP converted", + StepConversionStatus::Failed => "STEP blocked", + } + } +} + const DEMO_MODELS: [DemoModel; 2] = [ DemoModel { id: "body-f-chest-v4", @@ -213,6 +266,9 @@ impl ViewerStatus { #[component] pub fn ViewerPage(initial_model: DemoModel) -> Element { let mut selected_model = use_signal(|| initial_model); + let step_report = use_signal(StepConversionReport::idle); + let step_render_source: Signal> = use_signal(|| None); + let active_step_object_url: Signal> = use_signal(|| None); let canvas_mounted = use_signal(|| false); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); @@ -266,14 +322,32 @@ pub fn ViewerPage(initial_model: DemoModel) -> Element { button { class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" }, r#type: "button", - onclick: move |_| selected_model.set(*model_option), + onclick: move |_| { + clear_step_render_source(step_render_source, active_step_object_url); + selected_model.set(*model_option); + }, "{model_option.name}" } } + StepConversionControl { + step_report, + step_render_source, + active_step_object_url, + selected_model, + } + } + + div { + class: "viewer-step-conversion-panel", + "data-step-status": "{step_report.read().label()}", + p { class: "viewer-step-conversion-label", "OpenCascade.js STEP worker" } + p { "{step_report.read().details}" } } ModelViewer { model, + render_source: step_render_source, + fallback_to_demo: true, mesh_stats, status, canvas_mounted, @@ -287,7 +361,14 @@ pub fn ViewerPage(initial_model: DemoModel) -> Element { } #[component] -pub fn AssetViewer(initial_model: DemoModel, fallback: Option) -> Element { +pub fn AssetViewer( + initial_model: DemoModel, + fallback: Option, + #[props(default)] source: Option, +) -> Element { + let render_source: Signal> = use_signal(|| None); + let active_step_object_url: Signal> = use_signal(|| None); + let step_report = use_signal(StepConversionReport::idle); let canvas_mounted = use_signal(|| false); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); @@ -296,24 +377,63 @@ pub fn AssetViewer(initial_model: DemoModel, fallback: Option = use_signal(|| { ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) }); + let fallback_to_demo = source.is_none(); let _mesh_load = use_resource(move || { let model = initial_model; let mut status = status; let mut mesh_stats = mesh_stats; + let source = source.clone(); + let mut render_source = render_source; + let active_step_object_url = active_step_object_url; + let mut step_report = step_report; async move { - status.set(ViewerStatus::Loading(SessionKey::new( - model.id, - model.generation, - ))); - status.set(ViewerStatus::Ready(SessionKey::new( - model.id, - model.generation, - ))); - mesh_stats.set(Some(MeshStats { - vertices: model.vertices as u32, - triangles: model.triangles as u32, - })); + let session_key = source + .as_ref() + .map(|source| SessionKey::new(source.key.clone(), model.generation)) + .unwrap_or_else(|| SessionKey::new(model.id, model.generation)); + let using_source = source.is_some(); + status.set(ViewerStatus::Loading(session_key.clone())); + if let Some(source) = source { + match source.format { + ViewerSourceFormat::Renderer(format) => { + clear_step_render_source(render_source, active_step_object_url); + render_source.set(Some(RenderSource { + key: source.key, + format, + primary_url: source.url, + })); + } + ViewerSourceFormat::Step => { + clear_step_render_source(render_source, active_step_object_url); + step_report.set(StepConversionReport { + status: StepConversionStatus::Converting, + details: format!( + "Converting {} in the STEP conversion worker.", + source.name + ), + }); + start_step_conversion( + step_report, + render_source, + active_step_object_url, + source.url, + source.key, + ); + } + } + } else { + clear_step_render_source(render_source, active_step_object_url); + } + status.set(ViewerStatus::Ready(session_key)); + mesh_stats.set(if using_source { + None + } else { + Some(MeshStats { + vertices: model.vertices as u32, + triangles: model.triangles as u32, + }) + }); } }); @@ -321,6 +441,8 @@ pub fn AssetViewer(initial_model: DemoModel, fallback: Option>, + mut active_step_object_url: Signal>, +) { + if let Some(old_url) = active_step_object_url.write().take() { + revoke_object_url(old_url); + } + step_render_source.set(None); +} + +#[cfg(not(target_arch = "wasm32"))] +fn revoke_object_url(_url: String) {} + +#[cfg(target_arch = "wasm32")] +fn revoke_object_url(url: String) { + let _ = web_sys::Url::revoke_object_url(&url); +} + +#[component] +fn StepConversionControl( + mut step_report: Signal, + step_render_source: Signal>, + active_step_object_url: Signal>, + mut selected_model: Signal, +) -> Element { + let converting = matches!(step_report.read().status, StepConversionStatus::Converting); + rsx! { + button { + class: "viewer-demo-button viewer-step-conversion-button", + r#type: "button", + disabled: converting, + onclick: move |_| { + selected_model.set(DEMO_MODELS[0]); + clear_step_render_source(step_render_source, active_step_object_url); + step_report.set(StepConversionReport { + status: StepConversionStatus::Converting, + details: "Loading /models/EInk_click_v101.step in the STEP conversion worker.".into(), + }); + start_step_conversion( + step_report, + step_render_source, + active_step_object_url, + "/models/EInk_click_v101.step".to_string(), + "fixture:EInk_click_v101.step".to_string(), + ); + }, + if converting { "Converting STEP…" } else { "Convert STEP fixture" } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn start_step_conversion( + mut step_report: Signal, + _step_render_source: Signal>, + _active_step_object_url: Signal>, + _source_url: String, + _source_key: String, +) { + step_report.set(StepConversionReport { + status: StepConversionStatus::Failed, + details: "STEP conversion is only available in the browser WASM build.".into(), + }); +} + +#[cfg(target_arch = "wasm32")] +fn start_step_conversion( + mut step_report: Signal, + mut step_render_source: Signal>, + mut active_step_object_url: Signal>, + source_url: String, + source_key: String, +) { + use wasm_bindgen::JsCast; + use wasm_bindgen::closure::Closure; + + let opts = web_sys::WorkerOptions::new(); + opts.set_type(web_sys::WorkerType::Module); + let worker = match web_sys::Worker::new_with_options("/step_worker_loader.js", &opts) { + Ok(worker) => worker, + Err(error) => { + step_report.set(StepConversionReport { + status: StepConversionStatus::Failed, + details: format!("Could not spawn STEP worker: {error:?}"), + }); + return; + } + }; + + let worker_for_message = worker.clone(); + let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { + let data = event.data(); + let kind = js_string_field(&data, "kind").unwrap_or_default(); + match kind.as_str() { + "ready" => { + let msg = js_sys::Object::new(); + let _ = js_sys::Reflect::set(&msg, &"command".into(), &"convert-step".into()); + let _ = js_sys::Reflect::set(&msg, &"stepUrl".into(), &source_url.clone().into()); + let _ = worker_for_message.post_message(&msg); + } + "converted" => { + let Some(buffer) = js_value_field(&data, "buffer") else { + step_report.set(StepConversionReport { + status: StepConversionStatus::Failed, + details: "STEP worker reported success without GLB bytes.".into(), + }); + worker_for_message.terminate(); + return; + }; + let bytes = js_sys::Uint8Array::new(&buffer); + let parts = js_sys::Array::new(); + parts.push(&bytes.buffer()); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("model/gltf-binary"); + let blob = + match web_sys::Blob::new_with_buffer_source_sequence_and_options(&parts, &opts) + { + Ok(blob) => blob, + Err(error) => { + step_report.set(StepConversionReport { + status: StepConversionStatus::Failed, + details: format!("Could not create GLB Blob: {error:?}"), + }); + worker_for_message.terminate(); + return; + } + }; + let url = match web_sys::Url::create_object_url_with_blob(&blob) { + Ok(url) => url, + Err(error) => { + step_report.set(StepConversionReport { + status: StepConversionStatus::Failed, + details: format!("Could not create GLB object URL: {error:?}"), + }); + worker_for_message.terminate(); + return; + } + }; + + if let Some(old_url) = active_step_object_url.write().take() { + let _ = web_sys::Url::revoke_object_url(&old_url); + } + active_step_object_url.set(Some(url.clone())); + step_render_source.set(Some(RenderSource { + key: format!( + "step:{}:{}", + source_key, + js_number_field(&data, "totalMs").unwrap_or_default() + ), + format: MeshFormat::Gltf, + primary_url: url, + })); + step_report.set(StepConversionReport { + status: StepConversionStatus::Converted, + details: conversion_summary(&data), + }); + worker_for_message.terminate(); + } + "error" => { + step_report.set(StepConversionReport { + status: StepConversionStatus::Failed, + details: worker_error_summary(&data), + }); + worker_for_message.terminate(); + } + _ => {} + } + }) as Box); + worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); + onmessage.forget(); + + let mut step_report_error = step_report; + let worker_for_error = worker.clone(); + let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { + step_report_error.set(StepConversionReport { + status: StepConversionStatus::Failed, + details: format!("STEP worker error: {}", event.message()), + }); + worker_for_error.terminate(); + }) as Box); + worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); + onerror.forget(); +} + +#[cfg(target_arch = "wasm32")] +fn js_value_field(value: &wasm_bindgen::JsValue, name: &str) -> Option { + js_sys::Reflect::get(value, &name.into()).ok() +} + +#[cfg(target_arch = "wasm32")] +fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option { + js_value_field(value, name).and_then(|v| v.as_string()) +} + +#[cfg(target_arch = "wasm32")] +fn js_number_field(value: &wasm_bindgen::JsValue, name: &str) -> Option { + js_value_field(value, name).and_then(|v| v.as_f64()) +} + +#[cfg(target_arch = "wasm32")] +fn conversion_summary(data: &wasm_bindgen::JsValue) -> String { + let total = js_number_field(data, "totalMs").unwrap_or_default(); + let step_bytes = js_number_field(data, "stepBytes").unwrap_or_default(); + let glb_bytes = js_number_field(data, "glbBytes").unwrap_or_default(); + let isolated = js_value_field(data, "crossOriginIsolated") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + format!( + "Converted STEP to GLB in {:.0} ms; STEP {:.1} MB → GLB {:.1} MB; crossOriginIsolated={isolated}.", + total, + step_bytes / 1_048_576.0, + glb_bytes / 1_048_576.0, + ) +} + +#[cfg(target_arch = "wasm32")] +fn worker_error_summary(data: &wasm_bindgen::JsValue) -> String { + let message = js_string_field(data, "message").unwrap_or_else(|| "unknown error".into()); + let total = js_number_field(data, "totalMs").unwrap_or_default(); + let isolated = js_value_field(data, "crossOriginIsolated") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + format!( + "STEP worker blocked after {:.0} ms: {message}; crossOriginIsolated={isolated}.", + total + ) +} + #[derive(Props, Clone, PartialEq)] pub struct ModelViewerProps { model: DemoModel, + render_source: Signal>, + fallback_to_demo: bool, mesh_stats: Signal>, status: Signal, canvas_mounted: Signal, @@ -355,6 +707,8 @@ pub fn ModelViewer(props: ModelViewerProps) -> Element { let mesh_stats = props.mesh_stats; let status = props.status; let model = props.model; + let render_source = props.render_source; + let fallback_to_demo = props.fallback_to_demo; let load_status = status.read().clone(); let status_val = if *renderer_failed.read() { @@ -411,9 +765,11 @@ pub fn ModelViewer(props: ModelViewerProps) -> Element { } } } - WorkerRendererSession { - model, - status, + WorkerRendererSession { + model, + render_source, + fallback_to_demo, + status, canvas_mounted, resize_count, context_acquisitions, @@ -456,6 +812,8 @@ fn AssetPreview(summary: MeshSummary, stats: Option) -> Element { #[component] fn WorkerRendererSession( model: DemoModel, + render_source: Signal>, + fallback_to_demo: bool, status: Signal, canvas_mounted: Signal, resize_count: Signal, @@ -466,6 +824,8 @@ fn WorkerRendererSession( rsx! { WorkerSessionInner { model, + render_source, + fallback_to_demo, status, canvas_mounted, resize_count, @@ -480,6 +840,8 @@ fn WorkerRendererSession( #[component] fn WorkerSessionInner( model: DemoModel, + render_source: Signal>, + fallback_to_demo: bool, status: Signal, canvas_mounted: Signal, resize_count: Signal, @@ -489,6 +851,8 @@ fn WorkerSessionInner( ) -> Element { let _ = ( model, + render_source, + fallback_to_demo, status, canvas_mounted, resize_count, @@ -503,6 +867,8 @@ fn WorkerSessionInner( #[component] fn WorkerSessionInner( model: DemoModel, + render_source: Signal>, + fallback_to_demo: bool, status: Signal, canvas_mounted: Signal, resize_count: Signal, @@ -519,6 +885,7 @@ fn WorkerSessionInner( // Persistent bridge state: the Worker + its message handler + listener guard. // Held in use_hook so it survives re-renders. let bridge = use_hook(|| Rc::new(RefCell::new(WorkerBridgeState::new()))); + let worker_ready = use_signal(|| false); // Spawn the worker + attach message handler once. let bridge_for_spawn = bridge.clone(); @@ -527,6 +894,7 @@ fn WorkerSessionInner( let context_acquisitions_clone = context_acquisitions; let renderer_failed_clone = renderer_failed; let mesh_stats_clone = mesh_stats; + let worker_ready_clone = worker_ready; let mut bt = bridge_for_spawn.borrow_mut(); if bt.worker.is_none() { @@ -535,6 +903,7 @@ fn WorkerSessionInner( context_acquisitions_clone, renderer_failed_clone, mesh_stats_clone, + worker_ready_clone, ); } }); @@ -543,6 +912,10 @@ fn WorkerSessionInner( let model_val = model; let bridge_for_canvas = bridge.clone(); use_effect(move || { + if !*worker_ready.read() { + return; + } + let canvas_present = *canvas_mounted.read(); let mut bt = bridge_for_canvas.borrow_mut(); @@ -566,21 +939,32 @@ fn WorkerSessionInner( let dpr = web_sys::window() .map(|w| w.device_pixel_ratio() as f32) .unwrap_or(1.0); - bt.send_command(&RendererCommand::Resize { css_w, css_h, dpr }); + bt.post_command(&RendererCommand::Resize { css_w, css_h, dpr }); bt.initial_resize_sent = true; } // Send LoadMesh after canvas + initial resize. - if bt.canvas_transferred - && bt.initial_resize_sent - && let Ok(load) = pending_load_for(model_val) - && bt.last_loaded_model_id != Some(load.model.id) - { - bt.send_command(&RendererCommand::LoadMesh { - format: load.format, - primary_url: load.model.asset_path.to_string(), + if bt.canvas_transferred && bt.initial_resize_sent { + let source = render_source.read().clone().or_else(|| { + fallback_to_demo + .then(|| { + pending_load_for(model_val).ok().map(|load| RenderSource { + key: load.model.id.to_string(), + format: load.format, + primary_url: load.model.asset_path.to_string(), + }) + }) + .flatten() }); - bt.last_loaded_model_id = Some(load.model.id); + if let Some(source) = source + && bt.last_loaded_key.as_deref() != Some(source.key.as_str()) + { + bt.send_command(&RendererCommand::LoadMesh { + format: source.format, + primary_url: source.primary_url, + }); + bt.last_loaded_key = Some(source.key); + } } // Flush queued commands. @@ -653,7 +1037,7 @@ struct WorkerBridgeState { worker: Option, canvas_transferred: bool, initial_resize_sent: bool, - last_loaded_model_id: Option<&'static str>, + last_loaded_key: Option, post_canvas_queue: Vec>, resize_cleanup: Option>, pointer_cleanup: Option>, @@ -682,7 +1066,7 @@ impl WorkerBridgeState { worker: None, canvas_transferred: false, initial_resize_sent: false, - last_loaded_model_id: None, + last_loaded_key: None, post_canvas_queue: Vec::new(), resize_cleanup: None, pointer_cleanup: None, @@ -700,6 +1084,7 @@ impl WorkerBridgeState { context_acquisitions: Signal, mut renderer_failed: Signal, mesh_stats: Signal>, + mut worker_ready: Signal, ) { use polymodel_renderer_protocol::deserialize_event; use wasm_bindgen::JsCast; @@ -727,6 +1112,7 @@ impl WorkerBridgeState { if let Some(s) = data.as_string() && s == "__worker_ready" { + worker_ready.set(true); return; } diff --git a/tools/renderer-step/README.md b/tools/renderer-step/README.md new file mode 100644 index 0000000..f7baff5 --- /dev/null +++ b/tools/renderer-step/README.md @@ -0,0 +1,47 @@ +# Renderer STEP conversion tooling + +This directory contains the OpenCascade.js build recipe and local measurement helpers for browser-side STEP conversion. + +```text +Dioxus app + ├─ renderer worker: `polymodel-renderer-worker` + └─ STEP conversion worker: `public/step_worker_loader.js` → `public/step_worker.js` +``` + +The STEP worker owns OpenCascade.js initialization, STEP import, tessellation, GLB export, and timing/error reporting. The app receives GLB bytes, creates a `Blob` object URL, and sends that URL to the existing renderer worker as `MeshFormat::Gltf`. + +## Expected OpenCascade.js artifacts + +The committed worker harness expects local/generated artifacts at: + +```text +public/vendor/opencascade/opencascade.js +public/vendor/opencascade/opencascade.wasm +``` + +These files are intentionally ignored by git. The current build is single-threaded, so it does not produce an `opencascade.worker.js` sidecar and does not require cross-origin isolation headers. + +## Custom build target + +`opencascade.yml` records the intended single-threaded STEP→GLB build. + +To build and stage the custom artifact from Docker: + +```sh +just build-opencascade-step +``` + +The recipe uses `donalffons/opencascade.js` by default. Override with `OPENCASCADE_JS_DOCKER_IMAGE=...` if a different build image is needed. + +## Full npm control + +To stage the published full build for a browser control run: + +```sh +node tools/renderer-step/prepare-full-npm-control.mjs +node tools/renderer-step/measure-assets.mjs +``` + +This copies only the published JS/WASM pair into `public/vendor/opencascade/`; it does not add npm dependencies or Cargo dependencies to the app. + +See `evidence.md` for measurements and browser observations. diff --git a/tools/renderer-step/build-and-stage-opencascade.mjs b/tools/renderer-step/build-and-stage-opencascade.mjs new file mode 100644 index 0000000..2665b71 --- /dev/null +++ b/tools/renderer-step/build-and-stage-opencascade.mjs @@ -0,0 +1,82 @@ +import { cp, mkdir, rm, stat, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(scriptDir, '..', '..'); +const buildDir = join(scriptDir, 'build'); +const publicDir = join(repoRoot, 'public', 'vendor', 'opencascade'); +const image = process.env.OPENCASCADE_JS_DOCKER_IMAGE || 'donalffons/opencascade.js'; +const yaml = 'opencascade.yml'; + +await mkdir(buildDir, { recursive: true }); +await cp(join(scriptDir, yaml), join(buildDir, yaml)); + +const dockerArgs = [ + 'run', + '--rm', + '-v', + `${buildDir}:/src`, + '-u', + `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`, + image, + yaml, +]; + +console.log(`building ${yaml} with ${image}`); +await run('docker', dockerArgs); + +await stageArtifacts(); + +async function stageArtifacts() { + const js = join(buildDir, 'opencascade.js'); + const wasm = join(buildDir, 'opencascade.wasm'); + + await assertFile(js); + await assertFile(wasm); + + await mkdir(publicDir, { recursive: true }); + + await cp(js, join(publicDir, 'opencascade.js')); + await cp(wasm, join(publicDir, 'opencascade.wasm')); + + await rm(join(publicDir, 'opencascade.step.js'), { force: true }); + await rm(join(publicDir, 'opencascade.step.wasm'), { force: true }); + await rm(join(publicDir, 'opencascade.step.worker.js'), { force: true }); + await rm(join(publicDir, 'opencascade.worker.js'), { force: true }); + + await writeFile( + join(publicDir, 'README.txt'), + [ + 'Local OpenCascade.js STEP conversion artifacts.', + 'Generated by tools/renderer-step/build-and-stage-opencascade.mjs.', + 'Single-threaded build: no pthread sidecar and no cross-origin isolation requirement.', + 'Generated/ignored; do not commit.', + '', + ].join('\n'), + ); + + console.log(`staged OpenCascade.js STEP artifacts into ${publicDir}`); +} + +async function assertFile(path) { + const info = await stat(path); + if (!info.isFile() || info.size === 0) { + throw new Error(`expected non-empty file at ${path}`); + } +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: 'inherit' }); + child.on('error', reject); + child.on('close', (code, signal) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${command} exited with ${signal || code}`)); + } + }); + }); +} diff --git a/tools/renderer-step/evidence.md b/tools/renderer-step/evidence.md new file mode 100644 index 0000000..f42181a --- /dev/null +++ b/tools/renderer-step/evidence.md @@ -0,0 +1,113 @@ +# Renderer STEP conversion evidence + +## Environment + +- Browser route tested: `http://127.0.0.1:49177/viewer/body-f-chest-v4` +- Fixture path tested: `public/models/EInk_click_v101.step` served as `/models/EInk_click_v101.step` +- Local tools observed: Node `v24.15.0`, npm `11.12.1`, `wasm-bindgen 0.2.121`, `dx 0.7.9`, no `emcc` in PATH. + +## Commands run + +```sh +just fix +cargo check -p polymodel --target wasm32-unknown-unknown --features web +cargo check --workspace +just check +just build-renderer-worker +node tools/design-detector/detect.mjs --json assets/styling/viewer.css +node tools/renderer-step/prepare-full-npm-control.mjs +node tools/renderer-step/measure-assets.mjs +cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal +dx serve --addr 127.0.0.1 --port 49177 --interactive false --watch false --hot-reload false --open false +``` + +`cargo check --workspace` and `just check` emitted an unrelated `jacquard-identity` dead-code warning for `invalidate_authority_chain`; checks still passed. + +## Asset sizes measured + +Measured with `node tools/renderer-step/measure-assets.mjs` after staging the single-threaded custom `opencascade.yml` Docker build via `just build-opencascade-step`: + +| asset | raw bytes | gzip bytes | note | +| --- | ---: | ---: | --- | +| `public/vendor/opencascade/opencascade.js` | 134,503 | 34,346 | single-thread custom JS staged from build output | +| `public/vendor/opencascade/opencascade.wasm` | 6,128,224 | 2,719,991 | single-thread custom WASM staged from build output | +| `public/vendor/opencascade/opencascade.worker.js` | missing | missing | expected for single-thread build | +| `public/step_worker_loader.js` | 300 | 230 | committed loader | +| `public/step_worker.js` | 7,449 | 2,222 | committed worker harness | + +Earlier control measurement of published `opencascade.js@1.1.1` was 65.9 MB raw / 13.7 MB gzip WASM and lacked the needed XDE/GLB bindings. The custom build is much smaller and exposes the required conversion path. + +## Browser observations + +Before clicking **Convert STEP fixture**, the browser fetched the Dioxus app and renderer worker assets. It did **not** fetch `step_worker_loader.js`, `step_worker.js`, `/models/EInk_click_v101.step`, or `/vendor/opencascade/*`. + +After clicking **Convert STEP fixture**, Playwright network output showed lazy fetches: + +```text +GET /step_worker_loader.js 200 +GET /step_worker.js 200 +GET /models/EInk_click_v101.step 200 +GET /vendor/opencascade/opencascade.js 200 +GET /vendor/opencascade/opencascade.wasm 200 +``` + +No browser-console error came from the STEP worker. The only console errors observed were Dioxus dev-server websocket messages caused by running with hot reload disabled: + +```text +WebSocket connection to 'ws://127.0.0.1:49177/_dioxus?build_id=0' failed: net::ERR_CONNECTION_REFUSED +``` + +The page reported `crossOriginIsolated=false`. The single-threaded custom build does not need COOP/COEP or a pthread worker sidecar. + +## Conversion result + +The single-threaded custom OpenCascade.js build initialized inside the browser STEP worker, fetched `EInk_click_v101.step`, converted it to GLB bytes, and posted a `converted` result back to the Dioxus app. + +The app passes the converted GLB object URL into the retained renderer session as `MeshFormat::Gltf`; the renderer worker loads and renders it successfully. Small electronics/PCB STEP fixtures need camera near-plane and orbit-distance settings that scale with the model bounds. + +Validation run after the handoff and camera-fit fixes: + +```sh +just check +``` + +`just check` passed, including the app check and `polymodel-renderer-worker` wasm check. + +### Earlier full-npm-control failures + +The published full npm control initialized inside a browser Worker far enough to validate bindings, fetch the STEP fixture, and load the 65.9 MB OCCT WASM. It could not perform the target STEP→GLB pipeline because the published package lacks required XDE/GLB bindings for this workflow. + +Observed worker failures while tightening the harness: + +```text +STEP worker blocked after 1325 ms: Message_ProgressRange_1 is missing from this OpenCascade.js build; crossOriginIsolated=false. +STEP worker blocked after 4350 ms: TDF_LabelSequence_1 is missing from this OpenCascade.js build; crossOriginIsolated=false. +``` + +The final worker harness fails fast with a required-binding inventory, so future runs report the missing custom-build surface directly rather than one symbol at a time. + +## Dependency separation + +The app crate does not depend on OCCT or the STEP worker through Cargo. The wasm app dependency-tree check showed only: + +```text +polymodel +├── polymodel-api +├── polymodel-renderer-protocol +``` + +and no matches for `three-d`, `three-d-asset`, `stl_io`, `polymodel-mesh`, or `opencascade` in the app tree. OCCT assets are runtime-loaded by `public/step_worker.js` only after a STEP source is converted. + +## What this evidence does and does not decide + +Decided: + +- The worker split is the correct architecture boundary for client-side STEP: CAD conversion stays in a separate STEP worker, and rendering remains in the renderer worker. +- The published full npm `opencascade.js@1.1.1` artifact is not a viable Polymodel STEP→GLB path: it is large and lacks required bindings for the target XDE/GLB workflow. +- A single-threaded custom OpenCascade.js build can convert STEP fixtures to GLB in a browser Worker without cross-origin isolation. +- The existing renderer can load converted GLB output through the current `blob:` object-URL handoff. + +Not decided: + +- A multithreaded pthread custom build was not measured. +- Whether the product path should keep the `blob:` handoff or add direct transferred GLB bytes to the renderer protocol. Direct bytes would avoid object URL lifetime/fetch semantics, but the current handoff works. diff --git a/tools/renderer-step/measure-assets.mjs b/tools/renderer-step/measure-assets.mjs new file mode 100644 index 0000000..babdada --- /dev/null +++ b/tools/renderer-step/measure-assets.mjs @@ -0,0 +1,23 @@ +import { gzipSync } from 'node:zlib'; +import { stat, readFile } from 'node:fs/promises'; + +const files = [ + 'public/vendor/opencascade/opencascade.js', + 'public/vendor/opencascade/opencascade.wasm', + 'public/vendor/opencascade/opencascade.worker.js', + 'public/step_worker_loader.js', + 'public/step_worker.js', +]; + +for (const file of files) { + try { + const info = await stat(file); + const bytes = await readFile(file); + console.log(`${file}\traw=${info.size}\tgzip=${gzipSync(bytes, { level: 9 }).byteLength}`); + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } + console.log(`${file}\tmissing`); + } +} diff --git a/tools/renderer-step/opencascade.yml b/tools/renderer-step/opencascade.yml new file mode 100644 index 0000000..4ac716e --- /dev/null +++ b/tools/renderer-step/opencascade.yml @@ -0,0 +1,41 @@ +mainBuild: + name: opencascade.js + bindings: + - symbol: STEPCAFControl_Reader + - symbol: STEPControl_Reader + - symbol: IFSelect_ReturnStatus + - symbol: TCollection_ExtendedString + - symbol: TCollection_AsciiString + - symbol: TDocStd_Document + - symbol: Handle_TDocStd_Document + - symbol: Message_ProgressRange + - symbol: XCAFDoc_DocumentTool + - symbol: XCAFDoc_ShapeTool + - symbol: Handle_XCAFDoc_ShapeTool + - symbol: TDF_LabelSequence + - symbol: TDF_Label + - symbol: TopoDS_Shape + - symbol: BRepTools + - symbol: BRepMesh_IncrementalMesh + - symbol: BRepMesh_DiscretRoot + - symbol: RWGltf_CafWriter + - symbol: TColStd_IndexedDataMapOfStringString + - symbol: CDM_Document + - symbol: Standard_Transient + - symbol: TDataStd_GenericEmpty + - symbol: TDF_Attribute + - symbol: NCollection_BaseSequence + - symbol: NCollection_BaseMap + - symbol: BRep_Builder + emccFlags: + - -O3 + - -sEXPORT_ES6=1 + - -sUSE_ES6_IMPORT_META=0 + - -sEXPORTED_RUNTIME_METHODS=['FS'] + - -sINITIAL_MEMORY=100MB + - -sMAXIMUM_MEMORY=4GB + - -sALLOW_MEMORY_GROWTH=1 + - -sUSE_FREETYPE=1 + - -sLLD_REPORT_UNDEFINED + - --no-entry + - -sENVIRONMENT='web,worker' diff --git a/tools/renderer-step/prepare-full-npm-control.mjs b/tools/renderer-step/prepare-full-npm-control.mjs new file mode 100644 index 0000000..5b80ddd --- /dev/null +++ b/tools/renderer-step/prepare-full-npm-control.mjs @@ -0,0 +1,30 @@ +import { mkdir, mkdtemp, rm, cp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const packageName = 'opencascade.js@1.1.1'; +const scratch = await mkdtemp(join(tmpdir(), 'polymodel-ocjs-')); + +try { + const { stdout } = await execFileAsync('npm', ['pack', packageName, '--silent'], { cwd: scratch }); + const tarball = stdout.trim().split('\n').at(-1); + const packageDir = join(scratch, 'package'); + await mkdir(packageDir); + await execFileAsync('tar', ['-xzf', tarball, '-C', packageDir, '--strip-components=1'], { cwd: scratch }); + + const outDir = join(process.cwd(), 'public/vendor/opencascade'); + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + await cp(join(packageDir, 'dist/opencascade.wasm.js'), join(outDir, 'opencascade.js')); + await cp(join(packageDir, 'dist/opencascade.wasm.wasm'), join(outDir, 'opencascade.wasm')); + await writeFile( + join(outDir, 'README.txt'), + 'Local control artifacts copied from opencascade.js@1.1.1. Generated/ignored; do not commit.\n', + ); + console.log(`staged ${packageName} into public/vendor/opencascade`); +} finally { + await rm(scratch, { recursive: true, force: true }); +}