···1818# The loader JS is hand-authored and committed; the .js and .wasm are generated.
1919/public/renderer_worker.js
2020/public/renderer_worker_bg.wasm
2121+# Locally-copied/generated OpenCascade.js artifacts are measured, not committed.
2222+# The committed worker harness documents expected filenames.
2323+/public/vendor/opencascade/
2424+/tools/renderer-step/build/
2525+**.log
2126# Local databases (SQLite projection + Hydrant fjall store).
2227/data/
2328/hydrant.db/
···222222CREATE TABLE upload_staging (
223223 owner_did TEXT NOT NULL,
224224 upload_id TEXT NOT NULL,
225225- sha256 TEXT NOT NULL,
225225+ sha256 BLOB NOT NULL,
226226 mime_type TEXT NOT NULL,
227227 size INTEGER NOT NULL,
228228 filename TEXT,
+2-2
public/renderer_worker_loader.js
···33// function that must be called with the WASM module path before the worker
44// can receive messages. This loader does that and is the entry point for the
55// module worker spawned by WorkerBridge.
66-import init from './renderer_worker.js';
77-await init({ module_or_path: './renderer_worker_bg.wasm' });
66+const { default: init } = await import(new URL('/renderer_worker.js', self.location.origin));
77+await init({ module_or_path: '/renderer_worker_bg.wasm' });
···11+// This file is deliberately hand-authored and tiny. The heavy OpenCascade.js
22+// artifacts live under /vendor/opencascade/ and are not bundled into the
33+// Dioxus app or renderer worker.
44+import './step_worker.js';
+44-32
src/appview/downloads.rs
···22use axum::extract::{Path, State};
33use axum::http::{StatusCode, header};
44use axum::response::Response;
55+use futures::TryStreamExt;
66+use jacquard::identity::resolver::IdentityResolver;
57use jacquard_common::types::string::{Cid, Did};
66-use jacquard_common::xrpc::XrpcClient;
88+use jacquard_common::xrpc::XrpcExt;
79use polymodel_api::com_atproto::sync::get_blob::GetBlob;
810use polymodel_api::space_polymodel::library::File;
911use sha2::{Digest, Sha256};
···4648 }
4749 validate_file_manifest(&file)?;
48505151+ let did_doc_response = state
5252+ .resolver
5353+ .resolve_did_doc(&did)
5454+ .await
5555+ .map_err(|e| internal(format!("DID document resolution failed: {e}")))?;
5656+ let did_doc = did_doc_response
5757+ .parse()
5858+ .map_err(|e| internal(format!("DID document parse failed: {e}")))?;
5959+ let pds_endpoint = did_doc
6060+ .pds_endpoint()
6161+ .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))?
6262+ .to_owned();
6363+4964 let mut chunks = file.chunks;
5065 chunks.sort_by_key(|chunk| chunk.offset);
5166 let mut bytes = Vec::with_capacity(file.size as usize);
5267 for chunk in chunks {
5368 let cid: Cid = Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes())
5469 .map_err(|e| internal(format!("part blob CID is invalid: {e}")))?;
7070+ let request = GetBlob {
7171+ did: did.clone(),
7272+ cid,
7373+ };
5574 let response = state
5656- .bsky
5757- .send(GetBlob {
5858- did: did.clone(),
5959- cid,
6060- })
7575+ .resolver
7676+ .xrpc(pds_endpoint.borrow())
7777+ .download(&request)
6178 .await
6262- .map_err(|e| internal(format!("blob fetch failed: {e}")))?;
6363- let output = response
6464- .into_output()
6565- .map_err(|e| internal(format!("blob response decode failed: {e}")))?;
6666- if output.body.len() != chunk.size as usize {
7979+ .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?;
8080+ if !response.status().is_success() {
8181+ return Err(internal(format!(
8282+ "blob fetch from {pds_endpoint} failed with HTTP {}",
8383+ response.status()
8484+ )));
8585+ }
8686+ let chunk_start = bytes.len();
8787+ let (_, body) = response.into_parts();
8888+ let mut stream = body.into_inner();
8989+ while let Some(chunk_bytes) = stream
9090+ .try_next()
9191+ .await
9292+ .map_err(|e| internal(format!("blob stream from {pds_endpoint} failed: {e}")))?
9393+ {
9494+ bytes.extend_from_slice(&chunk_bytes);
9595+ }
9696+ if bytes.len() - chunk_start != chunk.size as usize {
6797 return Err(internal("blob chunk length did not match manifest"));
6898 }
6969- bytes.extend_from_slice(&output.body);
7099 }
71100 if bytes.len() != file.size as usize {
72101 return Err(internal("downloaded file length did not match manifest"));
73102 }
74103 if let Some(digest) = file.digest.as_ref() {
7575- let actual = hex_sha256(&bytes);
7676- let digest: &str = digest.as_ref();
7777- let expected = digest.strip_prefix("sha256:").unwrap_or(digest);
7878- if actual != expected {
104104+ let actual = Sha256::digest(&bytes);
105105+ if actual[..] != *digest.as_ref() {
79106 return Err(internal("downloaded file digest did not match manifest"));
80107 }
81108 }
···124151 Ok(())
125152}
126153127127-fn hex_sha256(bytes: &[u8]) -> String {
128128- Sha256::digest(bytes)
129129- .iter()
130130- .map(|byte| format!("{byte:02x}"))
131131- .collect()
132132-}
133133-134154fn download_filename(name: &str) -> String {
135155 let cleaned = name
136156 .chars()
···148168149169#[cfg(test)]
150170mod tests {
151151- use super::{download_filename, hex_sha256, validate_file_manifest};
171171+ use super::{download_filename, validate_file_manifest};
152172 use jacquard_common::deps::smol_str::SmolStr;
153173 use jacquard_common::types::blob::{Blob, BlobRef, MimeType};
154174 use jacquard_common::types::cid::CidLink;
···190210 let gapped = Chunk::builder().blob(blob_ref()).offset(2).size(4).build();
191211 assert!(validate_file_manifest(&file(vec![gapped], 6)).is_err());
192212 assert!(validate_file_manifest(&file(vec![chunk], 101 * 1024 * 1024)).is_err());
193193- }
194194-195195- #[test]
196196- fn hex_sha256_matches_known_digest() {
197197- assert_eq!(
198198- hex_sha256(b"solid cube"),
199199- "d3a15aa3cd30cc79123d6a50d2809ed794a452e67fa857bbc7ac343cbfca9971"
200200- );
201213 }
202214}
···11+# Renderer STEP conversion tooling
22+33+This directory contains the OpenCascade.js build recipe and local measurement helpers for browser-side STEP conversion.
44+55+```text
66+Dioxus app
77+ ├─ renderer worker: `polymodel-renderer-worker`
88+ └─ STEP conversion worker: `public/step_worker_loader.js` → `public/step_worker.js`
99+```
1010+1111+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`.
1212+1313+## Expected OpenCascade.js artifacts
1414+1515+The committed worker harness expects local/generated artifacts at:
1616+1717+```text
1818+public/vendor/opencascade/opencascade.js
1919+public/vendor/opencascade/opencascade.wasm
2020+```
2121+2222+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.
2323+2424+## Custom build target
2525+2626+`opencascade.yml` records the intended single-threaded STEP→GLB build.
2727+2828+To build and stage the custom artifact from Docker:
2929+3030+```sh
3131+just build-opencascade-step
3232+```
3333+3434+The recipe uses `donalffons/opencascade.js` by default. Override with `OPENCASCADE_JS_DOCKER_IMAGE=...` if a different build image is needed.
3535+3636+## Full npm control
3737+3838+To stage the published full build for a browser control run:
3939+4040+```sh
4141+node tools/renderer-step/prepare-full-npm-control.mjs
4242+node tools/renderer-step/measure-assets.mjs
4343+```
4444+4545+This copies only the published JS/WASM pair into `public/vendor/opencascade/`; it does not add npm dependencies or Cargo dependencies to the app.
4646+4747+See `evidence.md` for measurements and browser observations.
···11+# Renderer STEP conversion evidence
22+33+## Environment
44+55+- Browser route tested: `http://127.0.0.1:49177/viewer/body-f-chest-v4`
66+- Fixture path tested: `public/models/EInk_click_v101.step` served as `/models/EInk_click_v101.step`
77+- Local tools observed: Node `v24.15.0`, npm `11.12.1`, `wasm-bindgen 0.2.121`, `dx 0.7.9`, no `emcc` in PATH.
88+99+## Commands run
1010+1111+```sh
1212+just fix
1313+cargo check -p polymodel --target wasm32-unknown-unknown --features web
1414+cargo check --workspace
1515+just check
1616+just build-renderer-worker
1717+node tools/design-detector/detect.mjs --json assets/styling/viewer.css
1818+node tools/renderer-step/prepare-full-npm-control.mjs
1919+node tools/renderer-step/measure-assets.mjs
2020+cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal
2121+dx serve --addr 127.0.0.1 --port 49177 --interactive false --watch false --hot-reload false --open false
2222+```
2323+2424+`cargo check --workspace` and `just check` emitted an unrelated `jacquard-identity` dead-code warning for `invalidate_authority_chain`; checks still passed.
2525+2626+## Asset sizes measured
2727+2828+Measured with `node tools/renderer-step/measure-assets.mjs` after staging the single-threaded custom `opencascade.yml` Docker build via `just build-opencascade-step`:
2929+3030+| asset | raw bytes | gzip bytes | note |
3131+| --- | ---: | ---: | --- |
3232+| `public/vendor/opencascade/opencascade.js` | 134,503 | 34,346 | single-thread custom JS staged from build output |
3333+| `public/vendor/opencascade/opencascade.wasm` | 6,128,224 | 2,719,991 | single-thread custom WASM staged from build output |
3434+| `public/vendor/opencascade/opencascade.worker.js` | missing | missing | expected for single-thread build |
3535+| `public/step_worker_loader.js` | 300 | 230 | committed loader |
3636+| `public/step_worker.js` | 7,449 | 2,222 | committed worker harness |
3737+3838+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.
3939+4040+## Browser observations
4141+4242+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/*`.
4343+4444+After clicking **Convert STEP fixture**, Playwright network output showed lazy fetches:
4545+4646+```text
4747+GET /step_worker_loader.js 200
4848+GET /step_worker.js 200
4949+GET /models/EInk_click_v101.step 200
5050+GET /vendor/opencascade/opencascade.js 200
5151+GET /vendor/opencascade/opencascade.wasm 200
5252+```
5353+5454+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:
5555+5656+```text
5757+WebSocket connection to 'ws://127.0.0.1:49177/_dioxus?build_id=0' failed: net::ERR_CONNECTION_REFUSED
5858+```
5959+6060+The page reported `crossOriginIsolated=false`. The single-threaded custom build does not need COOP/COEP or a pthread worker sidecar.
6161+6262+## Conversion result
6363+6464+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.
6565+6666+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.
6767+6868+Validation run after the handoff and camera-fit fixes:
6969+7070+```sh
7171+just check
7272+```
7373+7474+`just check` passed, including the app check and `polymodel-renderer-worker` wasm check.
7575+7676+### Earlier full-npm-control failures
7777+7878+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.
7979+8080+Observed worker failures while tightening the harness:
8181+8282+```text
8383+STEP worker blocked after 1325 ms: Message_ProgressRange_1 is missing from this OpenCascade.js build; crossOriginIsolated=false.
8484+STEP worker blocked after 4350 ms: TDF_LabelSequence_1 is missing from this OpenCascade.js build; crossOriginIsolated=false.
8585+```
8686+8787+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.
8888+8989+## Dependency separation
9090+9191+The app crate does not depend on OCCT or the STEP worker through Cargo. The wasm app dependency-tree check showed only:
9292+9393+```text
9494+polymodel
9595+├── polymodel-api
9696+├── polymodel-renderer-protocol
9797+```
9898+9999+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.
100100+101101+## What this evidence does and does not decide
102102+103103+Decided:
104104+105105+- 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.
106106+- 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.
107107+- A single-threaded custom OpenCascade.js build can convert STEP fixtures to GLB in a browser Worker without cross-origin isolation.
108108+- The existing renderer can load converted GLB output through the current `blob:` object-URL handoff.
109109+110110+Not decided:
111111+112112+- A multithreaded pthread custom build was not measured.
113113+- 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.