atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-17: spike opencascade STEP worker

Orual (Jun 29, 2026, 7:06 PM EDT) fbe988ef 662a51cf

+1242 -198
+5
.gitignore
··· 18 18 # The loader JS is hand-authored and committed; the .js and .wasm are generated. 19 19 /public/renderer_worker.js 20 20 /public/renderer_worker_bg.wasm 21 + # Locally-copied/generated OpenCascade.js artifacts are measured, not committed. 22 + # The committed worker harness documents expected filenames. 23 + /public/vendor/opencascade/ 24 + /tools/renderer-step/build/ 25 + **.log 21 26 # Local databases (SQLite projection + Hydrant fjall store). 22 27 /data/ 23 28 /hydrant.db/
+3
Cargo.lock
··· 4256 4256 "jacquard-oauth", 4257 4257 "jose-jwk", 4258 4258 "miette", 4259 + "n0-future", 4259 4260 "regex", 4260 4261 "regex-lite", 4261 4262 "reqwest", ··· 4566 4567 "k256", 4567 4568 "miette", 4568 4569 "mini-moka-wasm", 4570 + "n0-future", 4569 4571 "p256", 4570 4572 "p384", 4571 4573 "rand 0.8.6", ··· 6269 6271 "axum", 6270 6272 "axum-extra", 6271 6273 "base64 0.22.1", 6274 + "bytes", 6272 6275 "chrono", 6273 6276 "console_error_panic_hook", 6274 6277 "dioxus",
+7 -2
Cargo.toml
··· 3 3 resolver = "3" 4 4 5 5 [workspace.dependencies] 6 - jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky"] } 6 + jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky", "streaming"] } 7 7 jacquard-axum = { path = "../jacquard/crates/jacquard-axum" } 8 8 jacquard-common = { path = "../jacquard/crates/jacquard-common" } 9 9 jacquard-derive = { path = "../jacquard/crates/jacquard-derive" } ··· 122 122 # aspectRatio; ULID for opaque, sortable draft ids. Both server-only. 123 123 imagesize = { version = "0.13", optional = true } 124 124 ulid = { version = "1", optional = true } 125 + bytes = { version = "1.12.0", features = ["serde"] } 125 126 126 127 127 128 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] ··· 144 145 imagesize = "0.13" 145 146 jacquard-axum = { workspace = true } 146 147 keyring = "3" 148 + serde_json = "1.0" 147 149 ulid = "1" 148 150 libsqlite3-sys = { version = "0.37", features = ["bundled"] } 149 151 rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"] } ··· 174 176 lto = "fat" 175 177 codegen-units = 1 176 178 strip = "symbols" 179 + panic = "abort" 180 + 177 181 178 182 [profile.worker-release] 179 183 inherits = "release" 180 - opt-level = 3 184 + opt-level = "z" 181 185 lto = "fat" 186 + debug = false 182 187 codegen-units = 1 183 188 strip = "symbols" 184 189 panic = "abort"
+38
assets/styling/viewer.css
··· 74 74 color: var(--color-primary); 75 75 } 76 76 77 + .viewer-demo-button:focus-visible { 78 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 79 + outline-offset: 1px; 80 + } 81 + 82 + .viewer-demo-button:disabled { 83 + cursor: wait; 84 + opacity: 0.58; 85 + } 86 + 87 + .viewer-step-conversion-button { 88 + border-color: var(--color-action); 89 + color: var(--color-tertiary); 90 + } 91 + 92 + .viewer-step-conversion-panel { 93 + border: 1px solid var(--color-border); 94 + background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)); 95 + padding: 0.75rem; 96 + } 97 + 98 + .viewer-step-conversion-panel p { 99 + margin: 0; 100 + } 101 + 102 + .viewer-step-conversion-label { 103 + margin-bottom: 0.25rem; 104 + color: var(--color-primary); 105 + font-family: var(--font-mono); 106 + font-size: 0.72rem; 107 + text-transform: uppercase; 108 + letter-spacing: 0.08em; 109 + } 110 + 111 + .viewer-step-conversion-panel[data-step-status="STEP blocked"] { 112 + border-color: var(--color-error); 113 + } 114 + 77 115 .model-viewer-boundary { 78 116 position: relative; 79 117 overflow: hidden;
+7 -4
crates/polymodel-api/lexicons/space_polymodel_library_defs.json
··· 200 200 "model/stl", 201 201 "model/3mf", 202 202 "model/obj", 203 - "application/octet-stream" 203 + "application/octet-stream", 204 + "model/step", 205 + "application/step" 204 206 ], 205 207 "maxSize": 104857600 206 208 }, ··· 262 264 "maxLength": 4096 263 265 }, 264 266 "digest": { 265 - "type": "string", 266 - "description": "hex digest of the fully reassembled file (sha256 by convention)" 267 + "type": "bytes", 268 + "description": "sha256 digest of the fully reassembled file" 267 269 }, 268 270 "mimeType": { 269 271 "type": "string" 270 272 }, 271 273 "size": { 272 - "type": "integer" 274 + "type": "integer", 275 + "description": "size in bytes of the fully reassembled file" 273 276 } 274 277 } 275 278 },
+13 -14
crates/polymodel-api/src/space_polymodel/library.rs
··· 26 26 #[allow(unused_imports)] 27 27 use core::marker::PhantomData; 28 28 use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; 29 + use jacquard_common::deps::bytes::Bytes; 29 30 30 31 #[allow(unused_imports)] 31 32 use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; ··· 191 192 #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 192 193 pub struct File<S: BosStr = DefaultStr> { 193 194 pub chunks: Vec<library::Chunk<S>>, 194 - ///hex digest of the fully reassembled file (sha256 by convention) 195 + ///sha256 digest of the fully reassembled file 195 196 #[serde(skip_serializing_if = "Option::is_none")] 196 - pub digest: Option<S>, 197 + #[serde(default, with = "jacquard_common::opt_serde_bytes_helper")] 198 + pub digest: Option<Bytes>, 197 199 pub mime_type: S, 200 + ///size in bytes of the fully reassembled file 198 201 pub size: i64, 199 202 #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 200 203 pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, ··· 479 482 "model/3mf", 480 483 "model/obj", 481 484 "application/octet-stream", 485 + "model/step", 486 + "application/step", 482 487 ]; 483 488 let matched = accepted 484 489 .iter() ··· 498 503 path: ValidationPath::from_field("blob"), 499 504 accepted: vec![ 500 505 "model/stl".to_string(), "model/3mf".to_string(), "model/obj" 501 - .to_string(), "application/octet-stream".to_string() 506 + .to_string(), "application/octet-stream".to_string(), 507 + "model/step".to_string(), "application/step".to_string() 502 508 ], 503 509 actual: mime.to_string(), 504 510 }); ··· 1515 1521 ); 1516 1522 map.insert( 1517 1523 SmolStr::new_static("digest"), 1518 - LexObjectProperty::String(LexString { 1519 - description: Some( 1520 - CowStr::new_static( 1521 - "hex digest of the fully reassembled file (sha256 by convention)", 1522 - ), 1523 - ), 1524 - ..Default::default() 1525 - }), 1524 + LexObjectProperty::Bytes(LexBytes { ..Default::default() }), 1526 1525 ); 1527 1526 map.insert( 1528 1527 SmolStr::new_static("mimeType"), ··· 3310 3309 /// Builder for constructing an instance of this type. 3311 3310 pub struct FileBuilder<St: file_state::State, S: BosStr = DefaultStr> { 3312 3311 _state: PhantomData<fn() -> St>, 3313 - _fields: (Option<Vec<library::Chunk<S>>>, Option<S>, Option<S>, Option<i64>), 3312 + _fields: (Option<Vec<library::Chunk<S>>>, Option<Bytes>, Option<S>, Option<i64>), 3314 3313 _type: PhantomData<fn() -> S>, 3315 3314 } 3316 3315 ··· 3371 3370 3372 3371 impl<St: file_state::State, S: BosStr> FileBuilder<St, S> { 3373 3372 /// Set the `digest` field (optional) 3374 - pub fn digest(mut self, value: impl Into<Option<S>>) -> Self { 3373 + pub fn digest(mut self, value: impl Into<Option<Bytes>>) -> Self { 3375 3374 self._fields.1 = value.into(); 3376 3375 self 3377 3376 } 3378 3377 /// Set the `digest` field to an Option value (optional) 3379 - pub fn maybe_digest(mut self, value: Option<S>) -> Self { 3378 + pub fn maybe_digest(mut self, value: Option<Bytes>) -> Self { 3380 3379 self._fields.1 = value; 3381 3380 self 3382 3381 }
+23 -20
crates/polymodel-renderer-worker/src/worker.rs
··· 171 171 fn build_placeholder_mesh( 172 172 context: &three_d::Context, 173 173 ) -> three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial> { 174 + let mut mesh = three_d_asset::TriMesh { 175 + positions: three_d_asset::Positions::F32(vec![ 176 + three_d_asset::Vec3::new(0.0, 0.0, 0.18), 177 + three_d_asset::Vec3::new(-0.16, -0.12, -0.08), 178 + three_d_asset::Vec3::new(0.16, -0.12, -0.08), 179 + three_d_asset::Vec3::new(0.0, 0.18, -0.08), 180 + ]), 181 + indices: three_d_asset::Indices::U32(vec![0, 1, 2, 0, 2, 3, 0, 3, 1, 1, 3, 2]), 182 + normals: None, 183 + tangents: None, 184 + uvs: None, 185 + colors: None, 186 + }; 187 + mesh.compute_normals(); 188 + 174 189 three_d::Gm::new( 175 - three_d::Mesh::new( 176 - context, 177 - &three_d_asset::TriMesh { 178 - positions: three_d_asset::Positions::F32(vec![ 179 - three_d_asset::Vec3::new(0.0, 0.0, 0.0), 180 - three_d_asset::Vec3::new(0.0, 0.0, 0.0), 181 - three_d_asset::Vec3::new(0.0, 0.0, 0.0), 182 - ]), 183 - indices: three_d_asset::Indices::U32(vec![0, 1, 2]), 184 - normals: None, 185 - tangents: None, 186 - uvs: None, 187 - colors: None, 188 - }, 189 - ), 190 + three_d::Mesh::new(context, &mesh), 190 191 three_d::PhysicalMaterial { 191 192 albedo: three_d::Srgba::new_opaque(116, 180, 255), 192 193 roughness: 0.72, ··· 220 221 ..Default::default() 221 222 }, 222 223 ); 223 - let radius = fit.radius.max(1.0); 224 + let radius = fit.radius.max(0.001); 224 225 let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); 225 226 let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); 226 227 let viewport = three_d::Viewport::new_at_origo(pixel_width, pixel_height); ··· 230 231 target, 231 232 three_d::vec3(0.0, 0.0, 1.0), 232 233 three_d::degrees(45.0), 233 - (radius / 100.0).max(0.1), 234 - radius * 20.0, 234 + (radius / 1000.0).max(0.000001), 235 + radius * 100.0, 235 236 ); 236 - let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); 237 + let control = three_d::OrbitControl::new(target, radius * 0.01, radius * 50.0); 237 238 (mesh, camera, control) 238 239 } 239 240 ··· 770 771 let bytes = array.to_vec(); 771 772 match deserialize_command(&bytes) { 772 773 Ok(cmd) => handle_command(&state_for_msg, cmd), 773 - Err(e) => tracing::error!("failed to deserialize command: {e}"), 774 + Err(e) => { 775 + tracing::error!("failed to deserialize command: {e}"); 776 + } 774 777 } 775 778 } 776 779 }) as Box<dyn FnMut(web_sys::MessageEvent)>);
+6 -2
justfile
··· 29 29 # Required when regenerating the `.sqlx` cache, and re-run after editing migrations. 30 30 migrate: 31 31 mkdir -p ./data 32 - -cargo sqlx database create --database-url '{{ db-url }}' 33 32 cargo sqlx migrate run --source migrations --database-url '{{ db-url }}' 34 33 35 34 # Regenerate the committed `.sqlx` offline query cache after changing SQL. ··· 61 60 RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p polymodel-renderer-worker --bin renderer_worker --target wasm32-unknown-unknown --profile worker-release 62 61 wasm-bindgen target/wasm32-unknown-unknown/worker-release/renderer_worker.wasm --target web --out-dir public --no-typescript 63 62 @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 63 + 64 + # Build and stage the single-threaded OpenCascade.js STEP conversion artifacts. 65 + build-opencascade-step: 66 + node tools/renderer-step/build-and-stage-opencascade.mjs 67 + 64 68 # Build the Dioxus app for web (includes renderer worker build). 65 69 build-web: build-renderer-worker 66 70 dx build --platform web 67 71 68 72 # Build the Dioxus app for web (release, optimized). Strips debug symbols and runs wasm-opt. 69 - build-web-release: build-renderer-worker 73 + build-web-release: build-renderer-worker build-opencascade-step 70 74 #!/usr/bin/env bash 71 75 set -e 72 76 dx build --platform web --release --debug-symbols=false
+20 -74
lexicons/library/defs.json
··· 4 4 "defs": { 5 5 "actor": { 6 6 "type": "object", 7 - "required": [ 8 - "did", 9 - "handle" 10 - ], 7 + "required": ["did", "handle"], 11 8 "properties": { 12 9 "did": { 13 10 "type": "string", ··· 78 75 "type": "string" 79 76 } 80 77 }, 81 - "required": [ 82 - "x", 83 - "y", 84 - "z" 85 - ] 78 + "required": ["x", "y", "z"] 86 79 }, 87 80 "aspectRatio": { 88 81 "type": "object", 89 82 "description": "Width:height aspect ratio of an image, for layout before the blob loads.", 90 - "required": [ 91 - "width", 92 - "height" 93 - ], 83 + "required": ["width", "height"], 94 84 "properties": { 95 85 "width": { 96 86 "type": "integer" ··· 103 93 "image": { 104 94 "type": "object", 105 95 "description": "An authored image: a blob plus alt text and aspect ratio. Stored on records; the app view resolves it to #imageView.", 106 - "required": [ 107 - "image", 108 - "alt", 109 - "aspectRatio" 110 - ], 96 + "required": ["image", "alt", "aspectRatio"], 111 97 "properties": { 112 98 "image": { 113 99 "type": "blob", 114 - "accept": [ 115 - "image/png", 116 - "image/jpeg", 117 - "image/webp" 118 - ], 100 + "accept": ["image/png", "image/jpeg", "image/webp"], 119 101 "maxSize": 5242880 120 102 }, 121 103 "alt": { ··· 133 115 "imageView": { 134 116 "type": "object", 135 117 "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.", 136 - "required": [ 137 - "thumb", 138 - "fullsize" 139 - ], 118 + "required": ["thumb", "fullsize"], 140 119 "properties": { 141 120 "alt": { 142 121 "type": "string" ··· 165 144 "type": "string" 166 145 }, 167 146 "size": { 168 - "type": "integer" 147 + "type": "integer", 148 + "description": "size in bytes of the fully reassembled file" 169 149 }, 170 150 "digest": { 171 - "type": "string", 172 - "description": "hex digest of the fully reassembled file (sha256 by convention)" 151 + "type": "bytes", 152 + "description": "sha256 digest of the fully reassembled file" 173 153 }, 174 154 "chunks": { 175 155 "type": "array", ··· 180 160 "maxLength": 4096 181 161 } 182 162 }, 183 - "required": [ 184 - "mimeType", 185 - "size", 186 - "chunks" 187 - ] 163 + "required": ["mimeType", "size", "chunks"] 188 164 }, 189 165 "chunk": { 190 166 "type": "object", ··· 195 171 "model/stl", 196 172 "model/3mf", 197 173 "model/obj", 198 - "application/octet-stream" 174 + "application/octet-stream", 175 + "model/step", 176 + "application/step" 199 177 ], 200 178 "maxSize": 104857600 201 179 }, ··· 208 186 "minimum": 0 209 187 } 210 188 }, 211 - "required": [ 212 - "blob", 213 - "offset", 214 - "size" 215 - ] 189 + "required": ["blob", "offset", "size"] 216 190 }, 217 191 "thingView": { 218 192 "type": "object", ··· 384 358 }, 385 359 "modelView": { 386 360 "type": "object", 387 - "required": [ 388 - "uri", 389 - "cid", 390 - "author", 391 - "record", 392 - "indexedAt", 393 - "name", 394 - "partCount", 395 - "viewer" 396 - ], 361 + "required": ["uri", "cid", "author", "record", "indexedAt", "name", "partCount", "viewer"], 397 362 "properties": { 398 363 "uri": { 399 364 "type": "string", ··· 444 409 }, 445 410 "partView": { 446 411 "type": "object", 447 - "required": [ 448 - "uri", 449 - "cid", 450 - "author", 451 - "record", 452 - "indexedAt", 453 - "name", 454 - "file", 455 - "viewer" 456 - ], 412 + "required": ["uri", "cid", "author", "record", "indexedAt", "name", "file", "viewer"], 457 413 "properties": { 458 414 "uri": { 459 415 "type": "string", ··· 504 460 "ref": "#thingViewBasic" 505 461 } 506 462 }, 507 - "required": [ 508 - "thing" 509 - ] 463 + "required": ["thing"] 510 464 }, 511 465 "feedView": { 512 466 "type": "object", ··· 522 476 } 523 477 } 524 478 }, 525 - "required": [ 526 - "items" 527 - ] 479 + "required": ["items"] 528 480 }, 529 481 "actorViewDetailed": { 530 482 "type": "object", 531 483 "description": "Detailed hydrated actor/profile view for profile surfaces, modeled after app.bsky.actor profile view splits.", 532 - "required": [ 533 - "did", 534 - "handle", 535 - "record", 536 - "indexedAt", 537 - "viewer" 538 - ], 484 + "required": ["did", "handle", "record", "indexedAt", "viewer"], 539 485 "properties": { 540 486 "did": { 541 487 "type": "string",
+1 -1
migrations/0001_initial_schema.sql
··· 222 222 CREATE TABLE upload_staging ( 223 223 owner_did TEXT NOT NULL, 224 224 upload_id TEXT NOT NULL, 225 - sha256 TEXT NOT NULL, 225 + sha256 BLOB NOT NULL, 226 226 mime_type TEXT NOT NULL, 227 227 size INTEGER NOT NULL, 228 228 filename TEXT,
+2 -2
public/renderer_worker_loader.js
··· 3 3 // function that must be called with the WASM module path before the worker 4 4 // can receive messages. This loader does that and is the entry point for the 5 5 // module worker spawned by WorkerBridge. 6 - import init from './renderer_worker.js'; 7 - await init({ module_or_path: './renderer_worker_bg.wasm' }); 6 + const { default: init } = await import(new URL('/renderer_worker.js', self.location.origin)); 7 + await init({ module_or_path: '/renderer_worker_bg.wasm' });
+224
public/step_worker.js
··· 1 + const DEFAULT_STEP_URL = '/models/EInk_click_v101.step'; 2 + const DEFAULT_LIN_DEFLECTION = 0.1; 3 + const DEFAULT_ANG_DEFLECTION = 0.1; 4 + 5 + let openCascadePromise; 6 + 7 + self.postMessage({ kind: 'ready' }); 8 + 9 + self.onmessage = async (event) => { 10 + const msg = event.data || {}; 11 + if (msg.command !== 'convert-step') { 12 + return; 13 + } 14 + 15 + const timings = []; 16 + const startedAt = performance.now(); 17 + 18 + try { 19 + const stepUrl = msg.stepUrl || DEFAULT_STEP_URL; 20 + const linDeflection = Number(msg.linDeflection ?? DEFAULT_LIN_DEFLECTION); 21 + const angDeflection = Number(msg.angDeflection ?? DEFAULT_ANG_DEFLECTION); 22 + 23 + mark(timings, 'fetch:start'); 24 + const stepBytes = new Uint8Array(await (await fetch(stepUrl)).arrayBuffer()); 25 + mark(timings, 'fetch:end'); 26 + 27 + mark(timings, 'init:start'); 28 + const oc = await loadOpenCascade(); 29 + validateRequiredBindings(oc); 30 + mark(timings, 'init:end'); 31 + 32 + mark(timings, 'read:start'); 33 + writeFile(oc, '/model.step', stepBytes); 34 + const doc = readStepIntoDocument(oc, '/model.step'); 35 + mark(timings, 'read:end'); 36 + 37 + mark(timings, 'tessellate:start'); 38 + const shape = firstFreeShape(oc, doc); 39 + if (oc.BRepTools?.Clean && shape) { 40 + oc.BRepTools.Clean(shape, false); 41 + } 42 + if (shape) { 43 + new oc.BRepMesh_IncrementalMesh_2(shape, linDeflection, false, angDeflection, false); 44 + } 45 + mark(timings, 'tessellate:end'); 46 + 47 + mark(timings, 'export:start'); 48 + const glbBytes = exportDocumentToGlb(oc, doc, '/model.glb'); 49 + mark(timings, 'export:end'); 50 + 51 + self.postMessage({ 52 + kind: 'converted', 53 + timings, 54 + totalMs: performance.now() - startedAt, 55 + stepBytes: stepBytes.byteLength, 56 + glbBytes: glbBytes.byteLength, 57 + buffer: glbBytes.buffer, 58 + crossOriginIsolated: self.crossOriginIsolated === true, 59 + hardwareConcurrency: navigator.hardwareConcurrency || null, 60 + }, [glbBytes.buffer]); 61 + } catch (error) { 62 + self.postMessage({ 63 + kind: 'error', 64 + timings, 65 + totalMs: performance.now() - startedAt, 66 + message: error instanceof Error ? error.message : String(error), 67 + stack: error instanceof Error ? error.stack : undefined, 68 + crossOriginIsolated: self.crossOriginIsolated === true, 69 + hardwareConcurrency: navigator.hardwareConcurrency || null, 70 + }); 71 + } 72 + }; 73 + 74 + async function loadOpenCascade() { 75 + if (!openCascadePromise) { 76 + openCascadePromise = (async () => { 77 + const openCascadeUrl = new URL('/vendor/opencascade/opencascade.js', self.location.origin).href; 78 + const module = await import(openCascadeUrl); 79 + const init = module.default || module.initOpenCascade || module.init; 80 + if (typeof init !== 'function') { 81 + throw new Error('OpenCascade module did not export an init function'); 82 + } 83 + const options = { 84 + locateFile(path) { 85 + if (path.endsWith('.wasm')) { 86 + return '/vendor/opencascade/opencascade.wasm'; 87 + } 88 + return `/vendor/opencascade/${path}`; 89 + }, 90 + }; 91 + return init(options); 92 + })(); 93 + } 94 + return openCascadePromise; 95 + } 96 + 97 + function writeFile(oc, path, bytes) { 98 + if (oc.FS.analyzePath(path).exists) { 99 + oc.FS.unlink(path); 100 + } 101 + oc.FS.writeFile(path, bytes); 102 + } 103 + 104 + function validateRequiredBindings(oc) { 105 + const required = [ 106 + ['STEPCAFControl_Reader_1', 'STEPCAFControl_Reader'], 107 + ['TDocStd_Document'], 108 + ['Handle_TDocStd_Document_2'], 109 + ['TCollection_ExtendedString_1'], 110 + ['XCAFDoc_DocumentTool'], 111 + ['XCAFDoc_ShapeTool'], 112 + ['TDF_LabelSequence_1'], 113 + ['BRepMesh_IncrementalMesh_2', 'BRepMesh_IncrementalMesh'], 114 + ['RWGltf_CafWriter'], 115 + ['TCollection_AsciiString_2'], 116 + ['TColStd_IndexedDataMapOfStringString_1'], 117 + ]; 118 + const missing = required 119 + .filter((alternatives) => !alternatives.some((name) => oc[name] !== undefined)) 120 + .map((alternatives) => alternatives.join(' or ')); 121 + if (missing.length > 0) { 122 + throw new Error(`OpenCascade.js build is missing required STEP→GLB bindings: ${missing.join(', ')}`); 123 + } 124 + } 125 + 126 + function readStepIntoDocument(oc, path) { 127 + const reader = constructFirst(oc, ['STEPCAFControl_Reader_1', 'STEPCAFControl_Reader']); 128 + const readStatus = reader.ReadFile(path); 129 + if (readStatus !== undefined && isFailureStatus(oc, readStatus)) { 130 + throw new Error(`STEP read failed with status ${readStatus}`); 131 + } 132 + 133 + const doc = constructDocument(oc); 134 + const handle = construct(oc, 'Handle_TDocStd_Document_2', doc); 135 + const progress = optionalConstruct(oc, 'Message_ProgressRange_1'); 136 + const transferred = progress 137 + ? callFirst(reader, ['Transfer_1', 'Transfer'], [handle, progress]) 138 + : callFirst(reader, ['Transfer_1', 'Transfer'], [handle]); 139 + if (transferred === false) { 140 + throw new Error('STEP transfer into XDE document failed'); 141 + } 142 + return doc; 143 + } 144 + 145 + function constructDocument(oc) { 146 + const name = construct(oc, 'TCollection_ExtendedString_1'); 147 + return construct(oc, 'TDocStd_Document', name); 148 + } 149 + 150 + function firstFreeShape(oc, doc) { 151 + if (!oc.XCAFDoc_DocumentTool?.ShapeTool) { 152 + return undefined; 153 + } 154 + const shapeToolHandle = oc.XCAFDoc_DocumentTool.ShapeTool(doc.Main()); 155 + const shapeTool = shapeToolHandle?.get ? shapeToolHandle.get() : shapeToolHandle; 156 + const labels = construct(oc, 'TDF_LabelSequence_1'); 157 + shapeTool.GetFreeShapes(labels); 158 + if (labels.Length && labels.Length() < 1) { 159 + return undefined; 160 + } 161 + const label = labels.First(); 162 + return callFirst(oc.XCAFDoc_ShapeTool, ['GetShape_2', 'GetShape'], [label]); 163 + } 164 + 165 + function exportDocumentToGlb(oc, doc, outputPath) { 166 + if (!oc.RWGltf_CafWriter) { 167 + throw new Error('RWGltf_CafWriter is missing from this OpenCascade.js build'); 168 + } 169 + if (oc.FS.analyzePath(outputPath).exists) { 170 + oc.FS.unlink(outputPath); 171 + } 172 + const writer = new oc.RWGltf_CafWriter(construct(oc, 'TCollection_AsciiString_2', outputPath), true); 173 + const handle = construct(oc, 'Handle_TDocStd_Document_2', doc); 174 + const metadata = construct(oc, 'TColStd_IndexedDataMapOfStringString_1'); 175 + const progress = optionalConstruct(oc, 'Message_ProgressRange_1'); 176 + const exported = progress 177 + ? callFirst(writer, ['Perform_2', 'Perform'], [handle, metadata, progress]) 178 + : callFirst(writer, ['Perform_2', 'Perform'], [handle, metadata]); 179 + if (exported === false) { 180 + throw new Error('RWGltf_CafWriter failed to export GLB'); 181 + } 182 + return oc.FS.readFile(outputPath, { encoding: 'binary' }); 183 + } 184 + 185 + function construct(oc, name, ...args) { 186 + const ctor = oc[name]; 187 + if (typeof ctor !== 'function') { 188 + throw new Error(`${name} is missing from this OpenCascade.js build`); 189 + } 190 + return new ctor(...args); 191 + } 192 + 193 + function optionalConstruct(oc, name, ...args) { 194 + return typeof oc[name] === 'function' ? new oc[name](...args) : undefined; 195 + } 196 + 197 + function constructFirst(oc, names, ...args) { 198 + for (const name of names) { 199 + if (typeof oc[name] === 'function') { 200 + return new oc[name](...args); 201 + } 202 + } 203 + throw new Error(`${names.join(' or ')} is missing from this OpenCascade.js build`); 204 + } 205 + 206 + function callFirst(target, names, args) { 207 + for (const name of names) { 208 + if (typeof target?.[name] === 'function') { 209 + return target[name](...args); 210 + } 211 + } 212 + throw new Error(`missing method ${names.join(' or ')}`); 213 + } 214 + 215 + function isFailureStatus(oc, status) { 216 + if (typeof oc.IFSelect_ReturnStatus?.IFSelect_RetDone === 'number') { 217 + return status !== oc.IFSelect_ReturnStatus.IFSelect_RetDone; 218 + } 219 + return false; 220 + } 221 + 222 + function mark(timings, label) { 223 + timings.push({ label, ms: performance.now() }); 224 + }
+4
public/step_worker_loader.js
··· 1 + // This file is deliberately hand-authored and tiny. The heavy OpenCascade.js 2 + // artifacts live under /vendor/opencascade/ and are not bundled into the 3 + // Dioxus app or renderer worker. 4 + import './step_worker.js';
+44 -32
src/appview/downloads.rs
··· 2 2 use axum::extract::{Path, State}; 3 3 use axum::http::{StatusCode, header}; 4 4 use axum::response::Response; 5 + use futures::TryStreamExt; 6 + use jacquard::identity::resolver::IdentityResolver; 5 7 use jacquard_common::types::string::{Cid, Did}; 6 - use jacquard_common::xrpc::XrpcClient; 8 + use jacquard_common::xrpc::XrpcExt; 7 9 use polymodel_api::com_atproto::sync::get_blob::GetBlob; 8 10 use polymodel_api::space_polymodel::library::File; 9 11 use sha2::{Digest, Sha256}; ··· 46 48 } 47 49 validate_file_manifest(&file)?; 48 50 51 + let did_doc_response = state 52 + .resolver 53 + .resolve_did_doc(&did) 54 + .await 55 + .map_err(|e| internal(format!("DID document resolution failed: {e}")))?; 56 + let did_doc = did_doc_response 57 + .parse() 58 + .map_err(|e| internal(format!("DID document parse failed: {e}")))?; 59 + let pds_endpoint = did_doc 60 + .pds_endpoint() 61 + .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))? 62 + .to_owned(); 63 + 49 64 let mut chunks = file.chunks; 50 65 chunks.sort_by_key(|chunk| chunk.offset); 51 66 let mut bytes = Vec::with_capacity(file.size as usize); 52 67 for chunk in chunks { 53 68 let cid: Cid = Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes()) 54 69 .map_err(|e| internal(format!("part blob CID is invalid: {e}")))?; 70 + let request = GetBlob { 71 + did: did.clone(), 72 + cid, 73 + }; 55 74 let response = state 56 - .bsky 57 - .send(GetBlob { 58 - did: did.clone(), 59 - cid, 60 - }) 75 + .resolver 76 + .xrpc(pds_endpoint.borrow()) 77 + .download(&request) 61 78 .await 62 - .map_err(|e| internal(format!("blob fetch failed: {e}")))?; 63 - let output = response 64 - .into_output() 65 - .map_err(|e| internal(format!("blob response decode failed: {e}")))?; 66 - if output.body.len() != chunk.size as usize { 79 + .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?; 80 + if !response.status().is_success() { 81 + return Err(internal(format!( 82 + "blob fetch from {pds_endpoint} failed with HTTP {}", 83 + response.status() 84 + ))); 85 + } 86 + let chunk_start = bytes.len(); 87 + let (_, body) = response.into_parts(); 88 + let mut stream = body.into_inner(); 89 + while let Some(chunk_bytes) = stream 90 + .try_next() 91 + .await 92 + .map_err(|e| internal(format!("blob stream from {pds_endpoint} failed: {e}")))? 93 + { 94 + bytes.extend_from_slice(&chunk_bytes); 95 + } 96 + if bytes.len() - chunk_start != chunk.size as usize { 67 97 return Err(internal("blob chunk length did not match manifest")); 68 98 } 69 - bytes.extend_from_slice(&output.body); 70 99 } 71 100 if bytes.len() != file.size as usize { 72 101 return Err(internal("downloaded file length did not match manifest")); 73 102 } 74 103 if let Some(digest) = file.digest.as_ref() { 75 - let actual = hex_sha256(&bytes); 76 - let digest: &str = digest.as_ref(); 77 - let expected = digest.strip_prefix("sha256:").unwrap_or(digest); 78 - if actual != expected { 104 + let actual = Sha256::digest(&bytes); 105 + if actual[..] != *digest.as_ref() { 79 106 return Err(internal("downloaded file digest did not match manifest")); 80 107 } 81 108 } ··· 124 151 Ok(()) 125 152 } 126 153 127 - fn hex_sha256(bytes: &[u8]) -> String { 128 - Sha256::digest(bytes) 129 - .iter() 130 - .map(|byte| format!("{byte:02x}")) 131 - .collect() 132 - } 133 - 134 154 fn download_filename(name: &str) -> String { 135 155 let cleaned = name 136 156 .chars() ··· 148 168 149 169 #[cfg(test)] 150 170 mod tests { 151 - use super::{download_filename, hex_sha256, validate_file_manifest}; 171 + use super::{download_filename, validate_file_manifest}; 152 172 use jacquard_common::deps::smol_str::SmolStr; 153 173 use jacquard_common::types::blob::{Blob, BlobRef, MimeType}; 154 174 use jacquard_common::types::cid::CidLink; ··· 190 210 let gapped = Chunk::builder().blob(blob_ref()).offset(2).size(4).build(); 191 211 assert!(validate_file_manifest(&file(vec![gapped], 6)).is_err()); 192 212 assert!(validate_file_manifest(&file(vec![chunk], 101 * 1024 * 1024)).is_err()); 193 - } 194 - 195 - #[test] 196 - fn hex_sha256_matches_known_digest() { 197 - assert_eq!( 198 - hex_sha256(b"solid cube"), 199 - "d3a15aa3cd30cc79123d6a50d2809ed794a452e67fa857bbc7ac343cbfca9971" 200 - ); 201 213 } 202 214 }
+18 -15
src/appview/writes.rs
··· 10 10 use axum::body::Body; 11 11 use axum::extract::State; 12 12 use axum::http::{HeaderMap, header}; 13 + use bytes::Bytes; 13 14 use http_body_util::BodyExt; 14 15 use jacquard::client::{Agent, AgentSession, AgentSessionExt}; 15 16 use jacquard::identity::PublicResolver; ··· 122 123 123 124 let file = File { 124 125 chunks: staged.chunks, 125 - digest: Some(SmolStr::from(staged.digest.clone())), 126 + digest: Some(Bytes::copy_from_slice(&staged.digest)), 126 127 mime_type: SmolStr::from(mime_type.clone()), 127 128 size: staged.size as i64, 128 129 extra_data: None, 129 130 }; 130 - let upload_id = format!("{}-{}", &staged.digest[..16], staged.size); 131 + let upload_id = format!("{}-{}", hex_bytes(&staged.digest[..16]), staged.size); 131 132 persist_upload( 132 133 &state, 133 134 &actor, ··· 1373 1374 1374 1375 struct StreamedUpload { 1375 1376 chunks: Vec<Chunk>, 1376 - digest: String, 1377 + digest: Vec<u8>, 1377 1378 size: usize, 1378 1379 } 1379 1380 ··· 1435 1436 let offset = size - pending.len(); 1436 1437 upload_one_chunk(agent, mime_type, &mut chunks, offset, pending).await?; 1437 1438 } 1438 - let digest = hasher 1439 - .finalize() 1440 - .iter() 1441 - .map(|b| format!("{b:02x}")) 1442 - .collect(); 1439 + let digest = hasher.finalize().to_vec(); 1443 1440 Ok(StreamedUpload { 1444 1441 chunks, 1445 1442 digest, ··· 1489 1486 1490 1487 struct UploadPersistence<'a> { 1491 1488 upload_id: &'a str, 1492 - digest: &'a str, 1489 + digest: &'a [u8], 1493 1490 mime_type: &'a str, 1494 1491 size: usize, 1495 1492 filename: Option<&'a str>, ··· 1663 1660 } 1664 1661 1665 1662 #[cfg(test)] 1666 - fn hex_sha256(bytes: &[u8]) -> String { 1667 - let digest = Sha256::digest(bytes); 1668 - digest.iter().map(|b| format!("{b:02x}")).collect() 1663 + fn sha256_bytes(bytes: &[u8]) -> Vec<u8> { 1664 + Sha256::digest(bytes).to_vec() 1665 + } 1666 + 1667 + fn hex_bytes(bytes: &[u8]) -> String { 1668 + bytes.iter().map(|b| format!("{b:02x}")).collect() 1669 1669 } 1670 1670 1671 1671 fn agent_error(operation: &str, err: jacquard::client::AgentError) -> AppError { ··· 1864 1864 let state = state().await; 1865 1865 let actor = did(DID_A); 1866 1866 let bytes = b"solid cube"; 1867 - let digest = hex_sha256(bytes); 1867 + let digest = sha256_bytes(bytes); 1868 1868 let file = File { 1869 1869 chunks: Vec::new(), 1870 - digest: Some(SmolStr::from(digest.clone())), 1870 + digest: Some(Bytes::copy_from_slice(&digest)), 1871 1871 mime_type: SmolStr::from("model/stl"), 1872 1872 size: bytes.len() as i64, 1873 1873 extra_data: None, ··· 1889 1889 .unwrap(); 1890 1890 1891 1891 let loaded = load_upload_file(&state, &actor, "upload-1").await.unwrap(); 1892 - assert_eq!(loaded.digest.as_deref(), Some(digest.as_str())); 1892 + assert_eq!( 1893 + loaded.digest.as_ref().map(Bytes::as_ref), 1894 + Some(digest.as_slice()) 1895 + ); 1893 1896 assert_eq!(loaded.mime_type.as_str(), "model/stl"); 1894 1897 assert_eq!(loaded.size, bytes.len() as i64); 1895 1898 assert!(
-1
src/indexing/sample_data.rs
··· 420 420 json!({ 421 421 "mimeType": "model/stl", 422 422 "size": size, 423 - "digest": "sha256:sample-polymodel-fixture", 424 423 "chunks": [{ "blob": blob("bafkreistlchunk", "model/stl", size), "offset": 0, "size": size }] 425 424 }) 426 425 }
+76 -2
src/thing_detail.rs
··· 3 3 use jacquard::common::types::recordkey::Rkey; 4 4 use jacquard::common::types::string::{AtUri, Cid}; 5 5 use jacquard::common::types::value::from_data; 6 + use jacquard_common::deps::smol_str::{SmolStr, StrExt}; 6 7 use jacquard_common::xrpc::XrpcClient; 7 8 use polymodel_api::com_atproto::repo::strong_ref::StrongRef; 8 9 use polymodel_api::space_polymodel::graph::{ ··· 10 11 delete_save::DeleteSave, 11 12 }; 12 13 use polymodel_api::space_polymodel::library; 14 + use polymodel_renderer_protocol::MeshFormat; 13 15 14 16 use crate::Route; 15 17 use crate::client::PolymodelClient; 16 18 use crate::profile::profile_href; 17 19 use crate::session::SessionIdentity; 18 - use crate::viewer::{AssetViewer, ViewerFallbackImage, demo_models}; 20 + use crate::viewer::{ 21 + AssetViewer, ViewerFallbackImage, ViewerMeshSource, ViewerSourceFormat, demo_models, 22 + }; 19 23 20 24 #[derive(Clone, Debug, PartialEq, Eq)] 21 25 enum DetailState { ··· 89 93 uri.authority().as_str(), 90 94 rkey.as_ref() 91 95 )) 96 + } 97 + 98 + fn viewer_source_for_part(part: &PartDetail) -> Option<ViewerMeshSource> { 99 + let url = part_download_href(&part.view.uri)?; 100 + let name = clean_text(Some(part.view.name.as_ref())) 101 + .unwrap_or("Untitled part") 102 + .to_string(); 103 + let format = part_viewer_format(part, &url, &name)?; 104 + Some(ViewerMeshSource { 105 + key: part.view.uri.as_str().to_string(), 106 + name, 107 + url, 108 + format, 109 + }) 110 + } 111 + 112 + fn part_viewer_format(part: &PartDetail, url: &str, name: &str) -> Option<ViewerSourceFormat> { 113 + [ 114 + Some(part.view.file.mime_type.as_str()), 115 + Some(url), 116 + Some(name), 117 + part.view.format.as_ref().map(|format| format.as_str()), 118 + part.record.format.as_ref().map(|format| format.as_str()), 119 + ] 120 + .into_iter() 121 + .flatten() 122 + .find_map(viewer_format_candidate) 123 + } 124 + 125 + fn viewer_format_candidate(candidate: &str) -> Option<ViewerSourceFormat> { 126 + let value = candidate.trim().to_lowercase_smolstr(); 127 + if matches!( 128 + value.as_str(), 129 + "step" | "stp" | "model/step" | "application/step" | "application/x-step" 130 + ) || extension_is(&value, "step") 131 + || extension_is(&value, "stp") 132 + { 133 + return Some(ViewerSourceFormat::Step); 134 + } 135 + if matches!(value.as_str(), "stl" | "model/stl" | "application/sla") { 136 + return Some(ViewerSourceFormat::Renderer(MeshFormat::Stl)); 137 + } 138 + if matches!(value.as_str(), "obj" | "model/obj") { 139 + return Some(ViewerSourceFormat::Renderer(MeshFormat::Obj)); 140 + } 141 + if matches!( 142 + value.as_str(), 143 + "gltf" | "glb" | "model/gltf+json" | "model/gltf-binary" 144 + ) { 145 + return Some(ViewerSourceFormat::Renderer(MeshFormat::Gltf)); 146 + } 147 + if matches!(value.as_str(), "3mf" | "model/3mf") { 148 + return Some(ViewerSourceFormat::Renderer(MeshFormat::Threemf)); 149 + } 150 + MeshFormat::from_extension(value.as_str()).map(ViewerSourceFormat::Renderer) 151 + } 152 + 153 + fn extension_is(value: &SmolStr, extension: &str) -> bool { 154 + value 155 + .as_str() 156 + .rsplit_once('.') 157 + .is_some_and(|(_, ext)| ext == extension) 92 158 } 93 159 94 160 #[cfg_attr(not(feature = "server"), allow(dead_code))] ··· 309 375 .as_ref() 310 376 .and_then(|selected| selected.parts.first()) 311 377 .and_then(|part| part_download_href(&part.view.uri)); 378 + let viewer_source = selected_model 379 + .as_ref() 380 + .and_then(|selected| selected.parts.first()) 381 + .and_then(viewer_source_for_part); 312 382 let instructions = thing_record.instructions; 313 383 314 384 rsx! { ··· 335 405 336 406 section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing overview", 337 407 div { class: "thing-detail-viewer", 338 - AssetViewer { initial_model: demo_models()[0], fallback: media.map(|media| ViewerFallbackImage { url: media.url, alt: media.alt }) } 408 + AssetViewer { 409 + initial_model: demo_models()[0], 410 + fallback: media.map(|media| ViewerFallbackImage { url: media.url, alt: media.alt }), 411 + source: viewer_source, 412 + } 339 413 } 340 414 341 415 div { class: "thing-detail-summary",
+415 -29
src/viewer.rs
··· 41 41 pub alt: String, 42 42 } 43 43 44 + #[derive(Clone, Debug, PartialEq, Eq)] 45 + pub enum ViewerSourceFormat { 46 + Renderer(MeshFormat), 47 + Step, 48 + } 49 + 50 + #[derive(Clone, Debug, PartialEq, Eq)] 51 + pub struct ViewerMeshSource { 52 + pub key: String, 53 + pub name: String, 54 + pub url: String, 55 + pub format: ViewerSourceFormat, 56 + } 57 + 58 + #[derive(Clone, Debug, PartialEq)] 59 + struct RenderSource { 60 + key: String, 61 + format: MeshFormat, 62 + primary_url: String, 63 + } 64 + 65 + #[derive(Clone, Debug, PartialEq)] 66 + struct StepConversionReport { 67 + status: StepConversionStatus, 68 + details: String, 69 + } 70 + 71 + #[derive(Clone, Debug, PartialEq)] 72 + enum StepConversionStatus { 73 + Idle, 74 + Converting, 75 + Converted, 76 + Failed, 77 + } 78 + 79 + impl StepConversionReport { 80 + fn idle() -> Self { 81 + Self { 82 + status: StepConversionStatus::Idle, 83 + details: "STEP conversion has not been requested.".into(), 84 + } 85 + } 86 + 87 + fn label(&self) -> &'static str { 88 + match self.status { 89 + StepConversionStatus::Idle => "STEP idle", 90 + StepConversionStatus::Converting => "STEP converting", 91 + StepConversionStatus::Converted => "STEP converted", 92 + StepConversionStatus::Failed => "STEP blocked", 93 + } 94 + } 95 + } 96 + 44 97 const DEMO_MODELS: [DemoModel; 2] = [ 45 98 DemoModel { 46 99 id: "body-f-chest-v4", ··· 213 266 #[component] 214 267 pub fn ViewerPage(initial_model: DemoModel) -> Element { 215 268 let mut selected_model = use_signal(|| initial_model); 269 + let step_report = use_signal(StepConversionReport::idle); 270 + let step_render_source: Signal<Option<RenderSource>> = use_signal(|| None); 271 + let active_step_object_url: Signal<Option<String>> = use_signal(|| None); 216 272 let canvas_mounted = use_signal(|| false); 217 273 let resize_count = use_signal(|| 0_u64); 218 274 let context_acquisitions = use_signal(|| 0_u64); ··· 266 322 button { 267 323 class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" }, 268 324 r#type: "button", 269 - onclick: move |_| selected_model.set(*model_option), 325 + onclick: move |_| { 326 + clear_step_render_source(step_render_source, active_step_object_url); 327 + selected_model.set(*model_option); 328 + }, 270 329 "{model_option.name}" 271 330 } 272 331 } 332 + StepConversionControl { 333 + step_report, 334 + step_render_source, 335 + active_step_object_url, 336 + selected_model, 337 + } 338 + } 339 + 340 + div { 341 + class: "viewer-step-conversion-panel", 342 + "data-step-status": "{step_report.read().label()}", 343 + p { class: "viewer-step-conversion-label", "OpenCascade.js STEP worker" } 344 + p { "{step_report.read().details}" } 273 345 } 274 346 275 347 ModelViewer { 276 348 model, 349 + render_source: step_render_source, 350 + fallback_to_demo: true, 277 351 mesh_stats, 278 352 status, 279 353 canvas_mounted, ··· 287 361 } 288 362 289 363 #[component] 290 - pub fn AssetViewer(initial_model: DemoModel, fallback: Option<ViewerFallbackImage>) -> Element { 364 + pub fn AssetViewer( 365 + initial_model: DemoModel, 366 + fallback: Option<ViewerFallbackImage>, 367 + #[props(default)] source: Option<ViewerMeshSource>, 368 + ) -> Element { 369 + let render_source: Signal<Option<RenderSource>> = use_signal(|| None); 370 + let active_step_object_url: Signal<Option<String>> = use_signal(|| None); 371 + let step_report = use_signal(StepConversionReport::idle); 291 372 let canvas_mounted = use_signal(|| false); 292 373 let resize_count = use_signal(|| 0_u64); 293 374 let context_acquisitions = use_signal(|| 0_u64); ··· 296 377 let status: Signal<ViewerStatus> = use_signal(|| { 297 378 ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) 298 379 }); 380 + let fallback_to_demo = source.is_none(); 299 381 300 382 let _mesh_load = use_resource(move || { 301 383 let model = initial_model; 302 384 let mut status = status; 303 385 let mut mesh_stats = mesh_stats; 386 + let source = source.clone(); 387 + let mut render_source = render_source; 388 + let active_step_object_url = active_step_object_url; 389 + let mut step_report = step_report; 304 390 async move { 305 - status.set(ViewerStatus::Loading(SessionKey::new( 306 - model.id, 307 - model.generation, 308 - ))); 309 - status.set(ViewerStatus::Ready(SessionKey::new( 310 - model.id, 311 - model.generation, 312 - ))); 313 - mesh_stats.set(Some(MeshStats { 314 - vertices: model.vertices as u32, 315 - triangles: model.triangles as u32, 316 - })); 391 + let session_key = source 392 + .as_ref() 393 + .map(|source| SessionKey::new(source.key.clone(), model.generation)) 394 + .unwrap_or_else(|| SessionKey::new(model.id, model.generation)); 395 + let using_source = source.is_some(); 396 + status.set(ViewerStatus::Loading(session_key.clone())); 397 + if let Some(source) = source { 398 + match source.format { 399 + ViewerSourceFormat::Renderer(format) => { 400 + clear_step_render_source(render_source, active_step_object_url); 401 + render_source.set(Some(RenderSource { 402 + key: source.key, 403 + format, 404 + primary_url: source.url, 405 + })); 406 + } 407 + ViewerSourceFormat::Step => { 408 + clear_step_render_source(render_source, active_step_object_url); 409 + step_report.set(StepConversionReport { 410 + status: StepConversionStatus::Converting, 411 + details: format!( 412 + "Converting {} in the STEP conversion worker.", 413 + source.name 414 + ), 415 + }); 416 + start_step_conversion( 417 + step_report, 418 + render_source, 419 + active_step_object_url, 420 + source.url, 421 + source.key, 422 + ); 423 + } 424 + } 425 + } else { 426 + clear_step_render_source(render_source, active_step_object_url); 427 + } 428 + status.set(ViewerStatus::Ready(session_key)); 429 + mesh_stats.set(if using_source { 430 + None 431 + } else { 432 + Some(MeshStats { 433 + vertices: model.vertices as u32, 434 + triangles: model.triangles as u32, 435 + }) 436 + }); 317 437 } 318 438 }); 319 439 ··· 321 441 div { class: "detail-asset-viewer", aria_label: "Asset-backed 3D model viewer", 322 442 ModelViewer { 323 443 model: initial_model, 444 + render_source, 445 + fallback_to_demo, 324 446 mesh_stats, 325 447 status, 326 448 canvas_mounted, ··· 333 455 } 334 456 } 335 457 458 + fn clear_step_render_source( 459 + mut step_render_source: Signal<Option<RenderSource>>, 460 + mut active_step_object_url: Signal<Option<String>>, 461 + ) { 462 + if let Some(old_url) = active_step_object_url.write().take() { 463 + revoke_object_url(old_url); 464 + } 465 + step_render_source.set(None); 466 + } 467 + 468 + #[cfg(not(target_arch = "wasm32"))] 469 + fn revoke_object_url(_url: String) {} 470 + 471 + #[cfg(target_arch = "wasm32")] 472 + fn revoke_object_url(url: String) { 473 + let _ = web_sys::Url::revoke_object_url(&url); 474 + } 475 + 476 + #[component] 477 + fn StepConversionControl( 478 + mut step_report: Signal<StepConversionReport>, 479 + step_render_source: Signal<Option<RenderSource>>, 480 + active_step_object_url: Signal<Option<String>>, 481 + mut selected_model: Signal<DemoModel>, 482 + ) -> Element { 483 + let converting = matches!(step_report.read().status, StepConversionStatus::Converting); 484 + rsx! { 485 + button { 486 + class: "viewer-demo-button viewer-step-conversion-button", 487 + r#type: "button", 488 + disabled: converting, 489 + onclick: move |_| { 490 + selected_model.set(DEMO_MODELS[0]); 491 + clear_step_render_source(step_render_source, active_step_object_url); 492 + step_report.set(StepConversionReport { 493 + status: StepConversionStatus::Converting, 494 + details: "Loading /models/EInk_click_v101.step in the STEP conversion worker.".into(), 495 + }); 496 + start_step_conversion( 497 + step_report, 498 + step_render_source, 499 + active_step_object_url, 500 + "/models/EInk_click_v101.step".to_string(), 501 + "fixture:EInk_click_v101.step".to_string(), 502 + ); 503 + }, 504 + if converting { "Converting STEP…" } else { "Convert STEP fixture" } 505 + } 506 + } 507 + } 508 + 509 + #[cfg(not(target_arch = "wasm32"))] 510 + fn start_step_conversion( 511 + mut step_report: Signal<StepConversionReport>, 512 + _step_render_source: Signal<Option<RenderSource>>, 513 + _active_step_object_url: Signal<Option<String>>, 514 + _source_url: String, 515 + _source_key: String, 516 + ) { 517 + step_report.set(StepConversionReport { 518 + status: StepConversionStatus::Failed, 519 + details: "STEP conversion is only available in the browser WASM build.".into(), 520 + }); 521 + } 522 + 523 + #[cfg(target_arch = "wasm32")] 524 + fn start_step_conversion( 525 + mut step_report: Signal<StepConversionReport>, 526 + mut step_render_source: Signal<Option<RenderSource>>, 527 + mut active_step_object_url: Signal<Option<String>>, 528 + source_url: String, 529 + source_key: String, 530 + ) { 531 + use wasm_bindgen::JsCast; 532 + use wasm_bindgen::closure::Closure; 533 + 534 + let opts = web_sys::WorkerOptions::new(); 535 + opts.set_type(web_sys::WorkerType::Module); 536 + let worker = match web_sys::Worker::new_with_options("/step_worker_loader.js", &opts) { 537 + Ok(worker) => worker, 538 + Err(error) => { 539 + step_report.set(StepConversionReport { 540 + status: StepConversionStatus::Failed, 541 + details: format!("Could not spawn STEP worker: {error:?}"), 542 + }); 543 + return; 544 + } 545 + }; 546 + 547 + let worker_for_message = worker.clone(); 548 + let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { 549 + let data = event.data(); 550 + let kind = js_string_field(&data, "kind").unwrap_or_default(); 551 + match kind.as_str() { 552 + "ready" => { 553 + let msg = js_sys::Object::new(); 554 + let _ = js_sys::Reflect::set(&msg, &"command".into(), &"convert-step".into()); 555 + let _ = js_sys::Reflect::set(&msg, &"stepUrl".into(), &source_url.clone().into()); 556 + let _ = worker_for_message.post_message(&msg); 557 + } 558 + "converted" => { 559 + let Some(buffer) = js_value_field(&data, "buffer") else { 560 + step_report.set(StepConversionReport { 561 + status: StepConversionStatus::Failed, 562 + details: "STEP worker reported success without GLB bytes.".into(), 563 + }); 564 + worker_for_message.terminate(); 565 + return; 566 + }; 567 + let bytes = js_sys::Uint8Array::new(&buffer); 568 + let parts = js_sys::Array::new(); 569 + parts.push(&bytes.buffer()); 570 + let opts = web_sys::BlobPropertyBag::new(); 571 + opts.set_type("model/gltf-binary"); 572 + let blob = 573 + match web_sys::Blob::new_with_buffer_source_sequence_and_options(&parts, &opts) 574 + { 575 + Ok(blob) => blob, 576 + Err(error) => { 577 + step_report.set(StepConversionReport { 578 + status: StepConversionStatus::Failed, 579 + details: format!("Could not create GLB Blob: {error:?}"), 580 + }); 581 + worker_for_message.terminate(); 582 + return; 583 + } 584 + }; 585 + let url = match web_sys::Url::create_object_url_with_blob(&blob) { 586 + Ok(url) => url, 587 + Err(error) => { 588 + step_report.set(StepConversionReport { 589 + status: StepConversionStatus::Failed, 590 + details: format!("Could not create GLB object URL: {error:?}"), 591 + }); 592 + worker_for_message.terminate(); 593 + return; 594 + } 595 + }; 596 + 597 + if let Some(old_url) = active_step_object_url.write().take() { 598 + let _ = web_sys::Url::revoke_object_url(&old_url); 599 + } 600 + active_step_object_url.set(Some(url.clone())); 601 + step_render_source.set(Some(RenderSource { 602 + key: format!( 603 + "step:{}:{}", 604 + source_key, 605 + js_number_field(&data, "totalMs").unwrap_or_default() 606 + ), 607 + format: MeshFormat::Gltf, 608 + primary_url: url, 609 + })); 610 + step_report.set(StepConversionReport { 611 + status: StepConversionStatus::Converted, 612 + details: conversion_summary(&data), 613 + }); 614 + worker_for_message.terminate(); 615 + } 616 + "error" => { 617 + step_report.set(StepConversionReport { 618 + status: StepConversionStatus::Failed, 619 + details: worker_error_summary(&data), 620 + }); 621 + worker_for_message.terminate(); 622 + } 623 + _ => {} 624 + } 625 + }) as Box<dyn FnMut(web_sys::MessageEvent)>); 626 + worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); 627 + onmessage.forget(); 628 + 629 + let mut step_report_error = step_report; 630 + let worker_for_error = worker.clone(); 631 + let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { 632 + step_report_error.set(StepConversionReport { 633 + status: StepConversionStatus::Failed, 634 + details: format!("STEP worker error: {}", event.message()), 635 + }); 636 + worker_for_error.terminate(); 637 + }) as Box<dyn FnMut(web_sys::ErrorEvent)>); 638 + worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); 639 + onerror.forget(); 640 + } 641 + 642 + #[cfg(target_arch = "wasm32")] 643 + fn js_value_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<wasm_bindgen::JsValue> { 644 + js_sys::Reflect::get(value, &name.into()).ok() 645 + } 646 + 647 + #[cfg(target_arch = "wasm32")] 648 + fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<String> { 649 + js_value_field(value, name).and_then(|v| v.as_string()) 650 + } 651 + 652 + #[cfg(target_arch = "wasm32")] 653 + fn js_number_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<f64> { 654 + js_value_field(value, name).and_then(|v| v.as_f64()) 655 + } 656 + 657 + #[cfg(target_arch = "wasm32")] 658 + fn conversion_summary(data: &wasm_bindgen::JsValue) -> String { 659 + let total = js_number_field(data, "totalMs").unwrap_or_default(); 660 + let step_bytes = js_number_field(data, "stepBytes").unwrap_or_default(); 661 + let glb_bytes = js_number_field(data, "glbBytes").unwrap_or_default(); 662 + let isolated = js_value_field(data, "crossOriginIsolated") 663 + .and_then(|v| v.as_bool()) 664 + .unwrap_or(false); 665 + format!( 666 + "Converted STEP to GLB in {:.0} ms; STEP {:.1} MB → GLB {:.1} MB; crossOriginIsolated={isolated}.", 667 + total, 668 + step_bytes / 1_048_576.0, 669 + glb_bytes / 1_048_576.0, 670 + ) 671 + } 672 + 673 + #[cfg(target_arch = "wasm32")] 674 + fn worker_error_summary(data: &wasm_bindgen::JsValue) -> String { 675 + let message = js_string_field(data, "message").unwrap_or_else(|| "unknown error".into()); 676 + let total = js_number_field(data, "totalMs").unwrap_or_default(); 677 + let isolated = js_value_field(data, "crossOriginIsolated") 678 + .and_then(|v| v.as_bool()) 679 + .unwrap_or(false); 680 + format!( 681 + "STEP worker blocked after {:.0} ms: {message}; crossOriginIsolated={isolated}.", 682 + total 683 + ) 684 + } 685 + 336 686 #[derive(Props, Clone, PartialEq)] 337 687 pub struct ModelViewerProps { 338 688 model: DemoModel, 689 + render_source: Signal<Option<RenderSource>>, 690 + fallback_to_demo: bool, 339 691 mesh_stats: Signal<Option<MeshStats>>, 340 692 status: Signal<ViewerStatus>, 341 693 canvas_mounted: Signal<bool>, ··· 355 707 let mesh_stats = props.mesh_stats; 356 708 let status = props.status; 357 709 let model = props.model; 710 + let render_source = props.render_source; 711 + let fallback_to_demo = props.fallback_to_demo; 358 712 359 713 let load_status = status.read().clone(); 360 714 let status_val = if *renderer_failed.read() { ··· 411 765 } 412 766 } 413 767 } 414 - WorkerRendererSession { 415 - model, 416 - status, 768 + WorkerRendererSession { 769 + model, 770 + render_source, 771 + fallback_to_demo, 772 + status, 417 773 canvas_mounted, 418 774 resize_count, 419 775 context_acquisitions, ··· 456 812 #[component] 457 813 fn WorkerRendererSession( 458 814 model: DemoModel, 815 + render_source: Signal<Option<RenderSource>>, 816 + fallback_to_demo: bool, 459 817 status: Signal<ViewerStatus>, 460 818 canvas_mounted: Signal<bool>, 461 819 resize_count: Signal<u64>, ··· 466 824 rsx! { 467 825 WorkerSessionInner { 468 826 model, 827 + render_source, 828 + fallback_to_demo, 469 829 status, 470 830 canvas_mounted, 471 831 resize_count, ··· 480 840 #[component] 481 841 fn WorkerSessionInner( 482 842 model: DemoModel, 843 + render_source: Signal<Option<RenderSource>>, 844 + fallback_to_demo: bool, 483 845 status: Signal<ViewerStatus>, 484 846 canvas_mounted: Signal<bool>, 485 847 resize_count: Signal<u64>, ··· 489 851 ) -> Element { 490 852 let _ = ( 491 853 model, 854 + render_source, 855 + fallback_to_demo, 492 856 status, 493 857 canvas_mounted, 494 858 resize_count, ··· 503 867 #[component] 504 868 fn WorkerSessionInner( 505 869 model: DemoModel, 870 + render_source: Signal<Option<RenderSource>>, 871 + fallback_to_demo: bool, 506 872 status: Signal<ViewerStatus>, 507 873 canvas_mounted: Signal<bool>, 508 874 resize_count: Signal<u64>, ··· 519 885 // Persistent bridge state: the Worker + its message handler + listener guard. 520 886 // Held in use_hook so it survives re-renders. 521 887 let bridge = use_hook(|| Rc::new(RefCell::new(WorkerBridgeState::new()))); 888 + let worker_ready = use_signal(|| false); 522 889 523 890 // Spawn the worker + attach message handler once. 524 891 let bridge_for_spawn = bridge.clone(); ··· 527 894 let context_acquisitions_clone = context_acquisitions; 528 895 let renderer_failed_clone = renderer_failed; 529 896 let mesh_stats_clone = mesh_stats; 897 + let worker_ready_clone = worker_ready; 530 898 531 899 let mut bt = bridge_for_spawn.borrow_mut(); 532 900 if bt.worker.is_none() { ··· 535 903 context_acquisitions_clone, 536 904 renderer_failed_clone, 537 905 mesh_stats_clone, 906 + worker_ready_clone, 538 907 ); 539 908 } 540 909 }); ··· 543 912 let model_val = model; 544 913 let bridge_for_canvas = bridge.clone(); 545 914 use_effect(move || { 915 + if !*worker_ready.read() { 916 + return; 917 + } 918 + 546 919 let canvas_present = *canvas_mounted.read(); 547 920 let mut bt = bridge_for_canvas.borrow_mut(); 548 921 ··· 566 939 let dpr = web_sys::window() 567 940 .map(|w| w.device_pixel_ratio() as f32) 568 941 .unwrap_or(1.0); 569 - bt.send_command(&RendererCommand::Resize { css_w, css_h, dpr }); 942 + bt.post_command(&RendererCommand::Resize { css_w, css_h, dpr }); 570 943 bt.initial_resize_sent = true; 571 944 } 572 945 573 946 // Send LoadMesh after canvas + initial resize. 574 - if bt.canvas_transferred 575 - && bt.initial_resize_sent 576 - && let Ok(load) = pending_load_for(model_val) 577 - && bt.last_loaded_model_id != Some(load.model.id) 578 - { 579 - bt.send_command(&RendererCommand::LoadMesh { 580 - format: load.format, 581 - primary_url: load.model.asset_path.to_string(), 947 + if bt.canvas_transferred && bt.initial_resize_sent { 948 + let source = render_source.read().clone().or_else(|| { 949 + fallback_to_demo 950 + .then(|| { 951 + pending_load_for(model_val).ok().map(|load| RenderSource { 952 + key: load.model.id.to_string(), 953 + format: load.format, 954 + primary_url: load.model.asset_path.to_string(), 955 + }) 956 + }) 957 + .flatten() 582 958 }); 583 - bt.last_loaded_model_id = Some(load.model.id); 959 + if let Some(source) = source 960 + && bt.last_loaded_key.as_deref() != Some(source.key.as_str()) 961 + { 962 + bt.send_command(&RendererCommand::LoadMesh { 963 + format: source.format, 964 + primary_url: source.primary_url, 965 + }); 966 + bt.last_loaded_key = Some(source.key); 967 + } 584 968 } 585 969 586 970 // Flush queued commands. ··· 653 1037 worker: Option<web_sys::Worker>, 654 1038 canvas_transferred: bool, 655 1039 initial_resize_sent: bool, 656 - last_loaded_model_id: Option<&'static str>, 1040 + last_loaded_key: Option<String>, 657 1041 post_canvas_queue: Vec<Vec<u8>>, 658 1042 resize_cleanup: Option<Box<dyn FnOnce()>>, 659 1043 pointer_cleanup: Option<Box<dyn FnOnce()>>, ··· 682 1066 worker: None, 683 1067 canvas_transferred: false, 684 1068 initial_resize_sent: false, 685 - last_loaded_model_id: None, 1069 + last_loaded_key: None, 686 1070 post_canvas_queue: Vec::new(), 687 1071 resize_cleanup: None, 688 1072 pointer_cleanup: None, ··· 700 1084 context_acquisitions: Signal<u64>, 701 1085 mut renderer_failed: Signal<bool>, 702 1086 mesh_stats: Signal<Option<MeshStats>>, 1087 + mut worker_ready: Signal<bool>, 703 1088 ) { 704 1089 use polymodel_renderer_protocol::deserialize_event; 705 1090 use wasm_bindgen::JsCast; ··· 727 1112 if let Some(s) = data.as_string() 728 1113 && s == "__worker_ready" 729 1114 { 1115 + worker_ready.set(true); 730 1116 return; 731 1117 } 732 1118
+47
tools/renderer-step/README.md
··· 1 + # Renderer STEP conversion tooling 2 + 3 + This directory contains the OpenCascade.js build recipe and local measurement helpers for browser-side STEP conversion. 4 + 5 + ```text 6 + Dioxus app 7 + ├─ renderer worker: `polymodel-renderer-worker` 8 + └─ STEP conversion worker: `public/step_worker_loader.js` → `public/step_worker.js` 9 + ``` 10 + 11 + 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`. 12 + 13 + ## Expected OpenCascade.js artifacts 14 + 15 + The committed worker harness expects local/generated artifacts at: 16 + 17 + ```text 18 + public/vendor/opencascade/opencascade.js 19 + public/vendor/opencascade/opencascade.wasm 20 + ``` 21 + 22 + 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. 23 + 24 + ## Custom build target 25 + 26 + `opencascade.yml` records the intended single-threaded STEP→GLB build. 27 + 28 + To build and stage the custom artifact from Docker: 29 + 30 + ```sh 31 + just build-opencascade-step 32 + ``` 33 + 34 + The recipe uses `donalffons/opencascade.js` by default. Override with `OPENCASCADE_JS_DOCKER_IMAGE=...` if a different build image is needed. 35 + 36 + ## Full npm control 37 + 38 + To stage the published full build for a browser control run: 39 + 40 + ```sh 41 + node tools/renderer-step/prepare-full-npm-control.mjs 42 + node tools/renderer-step/measure-assets.mjs 43 + ``` 44 + 45 + This copies only the published JS/WASM pair into `public/vendor/opencascade/`; it does not add npm dependencies or Cargo dependencies to the app. 46 + 47 + See `evidence.md` for measurements and browser observations.
+82
tools/renderer-step/build-and-stage-opencascade.mjs
··· 1 + import { cp, mkdir, rm, stat, writeFile } from 'node:fs/promises'; 2 + import { spawn } from 'node:child_process'; 3 + import { dirname, join } from 'node:path'; 4 + import { fileURLToPath } from 'node:url'; 5 + 6 + const scriptDir = dirname(fileURLToPath(import.meta.url)); 7 + const repoRoot = join(scriptDir, '..', '..'); 8 + const buildDir = join(scriptDir, 'build'); 9 + const publicDir = join(repoRoot, 'public', 'vendor', 'opencascade'); 10 + const image = process.env.OPENCASCADE_JS_DOCKER_IMAGE || 'donalffons/opencascade.js'; 11 + const yaml = 'opencascade.yml'; 12 + 13 + await mkdir(buildDir, { recursive: true }); 14 + await cp(join(scriptDir, yaml), join(buildDir, yaml)); 15 + 16 + const dockerArgs = [ 17 + 'run', 18 + '--rm', 19 + '-v', 20 + `${buildDir}:/src`, 21 + '-u', 22 + `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`, 23 + image, 24 + yaml, 25 + ]; 26 + 27 + console.log(`building ${yaml} with ${image}`); 28 + await run('docker', dockerArgs); 29 + 30 + await stageArtifacts(); 31 + 32 + async function stageArtifacts() { 33 + const js = join(buildDir, 'opencascade.js'); 34 + const wasm = join(buildDir, 'opencascade.wasm'); 35 + 36 + await assertFile(js); 37 + await assertFile(wasm); 38 + 39 + await mkdir(publicDir, { recursive: true }); 40 + 41 + await cp(js, join(publicDir, 'opencascade.js')); 42 + await cp(wasm, join(publicDir, 'opencascade.wasm')); 43 + 44 + await rm(join(publicDir, 'opencascade.step.js'), { force: true }); 45 + await rm(join(publicDir, 'opencascade.step.wasm'), { force: true }); 46 + await rm(join(publicDir, 'opencascade.step.worker.js'), { force: true }); 47 + await rm(join(publicDir, 'opencascade.worker.js'), { force: true }); 48 + 49 + await writeFile( 50 + join(publicDir, 'README.txt'), 51 + [ 52 + 'Local OpenCascade.js STEP conversion artifacts.', 53 + 'Generated by tools/renderer-step/build-and-stage-opencascade.mjs.', 54 + 'Single-threaded build: no pthread sidecar and no cross-origin isolation requirement.', 55 + 'Generated/ignored; do not commit.', 56 + '', 57 + ].join('\n'), 58 + ); 59 + 60 + console.log(`staged OpenCascade.js STEP artifacts into ${publicDir}`); 61 + } 62 + 63 + async function assertFile(path) { 64 + const info = await stat(path); 65 + if (!info.isFile() || info.size === 0) { 66 + throw new Error(`expected non-empty file at ${path}`); 67 + } 68 + } 69 + 70 + function run(command, args) { 71 + return new Promise((resolve, reject) => { 72 + const child = spawn(command, args, { stdio: 'inherit' }); 73 + child.on('error', reject); 74 + child.on('close', (code, signal) => { 75 + if (code === 0) { 76 + resolve(); 77 + } else { 78 + reject(new Error(`${command} exited with ${signal || code}`)); 79 + } 80 + }); 81 + }); 82 + }
+113
tools/renderer-step/evidence.md
··· 1 + # Renderer STEP conversion evidence 2 + 3 + ## Environment 4 + 5 + - Browser route tested: `http://127.0.0.1:49177/viewer/body-f-chest-v4` 6 + - Fixture path tested: `public/models/EInk_click_v101.step` served as `/models/EInk_click_v101.step` 7 + - Local tools observed: Node `v24.15.0`, npm `11.12.1`, `wasm-bindgen 0.2.121`, `dx 0.7.9`, no `emcc` in PATH. 8 + 9 + ## Commands run 10 + 11 + ```sh 12 + just fix 13 + cargo check -p polymodel --target wasm32-unknown-unknown --features web 14 + cargo check --workspace 15 + just check 16 + just build-renderer-worker 17 + node tools/design-detector/detect.mjs --json assets/styling/viewer.css 18 + node tools/renderer-step/prepare-full-npm-control.mjs 19 + node tools/renderer-step/measure-assets.mjs 20 + cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal 21 + dx serve --addr 127.0.0.1 --port 49177 --interactive false --watch false --hot-reload false --open false 22 + ``` 23 + 24 + `cargo check --workspace` and `just check` emitted an unrelated `jacquard-identity` dead-code warning for `invalidate_authority_chain`; checks still passed. 25 + 26 + ## Asset sizes measured 27 + 28 + Measured with `node tools/renderer-step/measure-assets.mjs` after staging the single-threaded custom `opencascade.yml` Docker build via `just build-opencascade-step`: 29 + 30 + | asset | raw bytes | gzip bytes | note | 31 + | --- | ---: | ---: | --- | 32 + | `public/vendor/opencascade/opencascade.js` | 134,503 | 34,346 | single-thread custom JS staged from build output | 33 + | `public/vendor/opencascade/opencascade.wasm` | 6,128,224 | 2,719,991 | single-thread custom WASM staged from build output | 34 + | `public/vendor/opencascade/opencascade.worker.js` | missing | missing | expected for single-thread build | 35 + | `public/step_worker_loader.js` | 300 | 230 | committed loader | 36 + | `public/step_worker.js` | 7,449 | 2,222 | committed worker harness | 37 + 38 + 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. 39 + 40 + ## Browser observations 41 + 42 + 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/*`. 43 + 44 + After clicking **Convert STEP fixture**, Playwright network output showed lazy fetches: 45 + 46 + ```text 47 + GET /step_worker_loader.js 200 48 + GET /step_worker.js 200 49 + GET /models/EInk_click_v101.step 200 50 + GET /vendor/opencascade/opencascade.js 200 51 + GET /vendor/opencascade/opencascade.wasm 200 52 + ``` 53 + 54 + 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: 55 + 56 + ```text 57 + WebSocket connection to 'ws://127.0.0.1:49177/_dioxus?build_id=0' failed: net::ERR_CONNECTION_REFUSED 58 + ``` 59 + 60 + The page reported `crossOriginIsolated=false`. The single-threaded custom build does not need COOP/COEP or a pthread worker sidecar. 61 + 62 + ## Conversion result 63 + 64 + 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. 65 + 66 + 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. 67 + 68 + Validation run after the handoff and camera-fit fixes: 69 + 70 + ```sh 71 + just check 72 + ``` 73 + 74 + `just check` passed, including the app check and `polymodel-renderer-worker` wasm check. 75 + 76 + ### Earlier full-npm-control failures 77 + 78 + 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. 79 + 80 + Observed worker failures while tightening the harness: 81 + 82 + ```text 83 + STEP worker blocked after 1325 ms: Message_ProgressRange_1 is missing from this OpenCascade.js build; crossOriginIsolated=false. 84 + STEP worker blocked after 4350 ms: TDF_LabelSequence_1 is missing from this OpenCascade.js build; crossOriginIsolated=false. 85 + ``` 86 + 87 + 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. 88 + 89 + ## Dependency separation 90 + 91 + The app crate does not depend on OCCT or the STEP worker through Cargo. The wasm app dependency-tree check showed only: 92 + 93 + ```text 94 + polymodel 95 + ├── polymodel-api 96 + ├── polymodel-renderer-protocol 97 + ``` 98 + 99 + 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. 100 + 101 + ## What this evidence does and does not decide 102 + 103 + Decided: 104 + 105 + - 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. 106 + - 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. 107 + - A single-threaded custom OpenCascade.js build can convert STEP fixtures to GLB in a browser Worker without cross-origin isolation. 108 + - The existing renderer can load converted GLB output through the current `blob:` object-URL handoff. 109 + 110 + Not decided: 111 + 112 + - A multithreaded pthread custom build was not measured. 113 + - 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.
+23
tools/renderer-step/measure-assets.mjs
··· 1 + import { gzipSync } from 'node:zlib'; 2 + import { stat, readFile } from 'node:fs/promises'; 3 + 4 + const files = [ 5 + 'public/vendor/opencascade/opencascade.js', 6 + 'public/vendor/opencascade/opencascade.wasm', 7 + 'public/vendor/opencascade/opencascade.worker.js', 8 + 'public/step_worker_loader.js', 9 + 'public/step_worker.js', 10 + ]; 11 + 12 + for (const file of files) { 13 + try { 14 + const info = await stat(file); 15 + const bytes = await readFile(file); 16 + console.log(`${file}\traw=${info.size}\tgzip=${gzipSync(bytes, { level: 9 }).byteLength}`); 17 + } catch (error) { 18 + if (error?.code !== 'ENOENT') { 19 + throw error; 20 + } 21 + console.log(`${file}\tmissing`); 22 + } 23 + }
+41
tools/renderer-step/opencascade.yml
··· 1 + mainBuild: 2 + name: opencascade.js 3 + bindings: 4 + - symbol: STEPCAFControl_Reader 5 + - symbol: STEPControl_Reader 6 + - symbol: IFSelect_ReturnStatus 7 + - symbol: TCollection_ExtendedString 8 + - symbol: TCollection_AsciiString 9 + - symbol: TDocStd_Document 10 + - symbol: Handle_TDocStd_Document 11 + - symbol: Message_ProgressRange 12 + - symbol: XCAFDoc_DocumentTool 13 + - symbol: XCAFDoc_ShapeTool 14 + - symbol: Handle_XCAFDoc_ShapeTool 15 + - symbol: TDF_LabelSequence 16 + - symbol: TDF_Label 17 + - symbol: TopoDS_Shape 18 + - symbol: BRepTools 19 + - symbol: BRepMesh_IncrementalMesh 20 + - symbol: BRepMesh_DiscretRoot 21 + - symbol: RWGltf_CafWriter 22 + - symbol: TColStd_IndexedDataMapOfStringString 23 + - symbol: CDM_Document 24 + - symbol: Standard_Transient 25 + - symbol: TDataStd_GenericEmpty 26 + - symbol: TDF_Attribute 27 + - symbol: NCollection_BaseSequence 28 + - symbol: NCollection_BaseMap 29 + - symbol: BRep_Builder 30 + emccFlags: 31 + - -O3 32 + - -sEXPORT_ES6=1 33 + - -sUSE_ES6_IMPORT_META=0 34 + - -sEXPORTED_RUNTIME_METHODS=['FS'] 35 + - -sINITIAL_MEMORY=100MB 36 + - -sMAXIMUM_MEMORY=4GB 37 + - -sALLOW_MEMORY_GROWTH=1 38 + - -sUSE_FREETYPE=1 39 + - -sLLD_REPORT_UNDEFINED 40 + - --no-entry 41 + - -sENVIRONMENT='web,worker'
+30
tools/renderer-step/prepare-full-npm-control.mjs
··· 1 + import { mkdir, mkdtemp, rm, cp, writeFile } from 'node:fs/promises'; 2 + import { tmpdir } from 'node:os'; 3 + import { join } from 'node:path'; 4 + import { execFile } from 'node:child_process'; 5 + import { promisify } from 'node:util'; 6 + 7 + const execFileAsync = promisify(execFile); 8 + const packageName = 'opencascade.js@1.1.1'; 9 + const scratch = await mkdtemp(join(tmpdir(), 'polymodel-ocjs-')); 10 + 11 + try { 12 + const { stdout } = await execFileAsync('npm', ['pack', packageName, '--silent'], { cwd: scratch }); 13 + const tarball = stdout.trim().split('\n').at(-1); 14 + const packageDir = join(scratch, 'package'); 15 + await mkdir(packageDir); 16 + await execFileAsync('tar', ['-xzf', tarball, '-C', packageDir, '--strip-components=1'], { cwd: scratch }); 17 + 18 + const outDir = join(process.cwd(), 'public/vendor/opencascade'); 19 + await rm(outDir, { recursive: true, force: true }); 20 + await mkdir(outDir, { recursive: true }); 21 + await cp(join(packageDir, 'dist/opencascade.wasm.js'), join(outDir, 'opencascade.js')); 22 + await cp(join(packageDir, 'dist/opencascade.wasm.wasm'), join(outDir, 'opencascade.wasm')); 23 + await writeFile( 24 + join(outDir, 'README.txt'), 25 + 'Local control artifacts copied from opencascade.js@1.1.1. Generated/ignored; do not commit.\n', 26 + ); 27 + console.log(`staged ${packageName} into public/vendor/opencascade`); 28 + } finally { 29 + await rm(scratch, { recursive: true, force: true }); 30 + }