From a1bcda4886ad5f53b65181cd070d5f231ab1350f Mon Sep 17 00:00:00 2001 From: Orual Date: Fri, 7 Aug 2026 13:15:57 -0400 Subject: [PATCH] PM-79: unify LDraw model bundle loading Serve complete indexed model resources through one generic XRPC bundle, use one worker StartLoad contract for interactive and preview output, preserve installed scenes through replacement cancellation, and prove zero per-resource fan-out. Epic: PM-86 Task: PM-79 --- ...cd32ab68fa1662e796c3cf592285285551b7c.json | 12 - ...1a8573669d421029964a768044f45d9ce0536.json | 12 - ...5622dd37ace1548fea17eb57fe1d55bcd73bd.json | 12 - ...ebe849023eb2d790b062342c6d853c9f7258c.json | 12 - ...02eff6a64c5e38cfb0255c21d4a55c6f99d8d.json | 12 - ...7e9817e2ca5fa7633c759b8620d25629518c3.json | 38 ++ ...2bae0284416437e5e39fcef7f1536d820bad3.json | 12 - ...999a329b310a341c35260ef101b9d40c901b8.json | 12 - ...5656b5589b0dd880e58444b8f6c0adf246668.json | 12 - ...54ec94684eb9f59480ff6fe27f2885a3a90bf.json | 12 - ...a94e0dd662048bf8caf7ea75f6561ebd819b5.json | 12 - ...1292182911f65972d1fe58cac0d3e0c212e4c.json | 21 - ..._polymodel_library_getModelLoadBundle.json | 25 ++ .../src/space_polymodel/library.rs | 1 + .../library/get_model_load_bundle.rs | 195 ++++++++ crates/polymodel-renderer-protocol/src/lib.rs | 420 ++++++++---------- .../polymodel-renderer-worker/src/worker.rs | 337 ++++++-------- e2e/tests/viewer.spec.ts | 56 ++- lexicons/library/getModelLoadBundle.json | 20 + src/appview/download_tests.rs | 6 +- src/appview/ldraw.rs | 129 +++--- src/appview/mod.rs | 10 +- src/appview/read_tests.rs | 58 ++- src/client.rs | 21 + src/publish.rs | 89 +++- src/thing_detail.rs | 16 +- src/viewer.rs | 100 ++--- 27 files changed, 882 insertions(+), 780 deletions(-) delete mode 100644 .sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json delete mode 100644 .sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json delete mode 100644 .sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json delete mode 100644 .sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json delete mode 100644 .sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json create mode 100644 .sqlx/query-9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3.json delete mode 100644 .sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json delete mode 100644 .sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json delete mode 100644 .sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json delete mode 100644 .sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json delete mode 100644 .sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json delete mode 100644 .sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json create mode 100644 crates/polymodel-api/lexicons/space_polymodel_library_getModelLoadBundle.json create mode 100644 crates/polymodel-api/src/space_polymodel/library/get_model_load_bundle.rs create mode 100644 lexicons/library/getModelLoadBundle.json diff --git a/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json b/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json deleted file mode 100644 index 5a176b0..0000000 --- a/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c" -} diff --git a/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json b/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json deleted file mode 100644 index f8a5c1d..0000000 --- a/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, ?, ?, 0)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536" -} diff --git a/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json b/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json deleted file mode 100644 index 2e73504..0000000 --- a/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO profiles (did, display_name, description, avatar_json, default_license,\n pronouns, printers_json, links_json, record_json, indexed_at)\n VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 8 - }, - "nullable": [] - }, - "hash": "111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd" -} diff --git a/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json b/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json deleted file mode 100644 index f8c1ed1..0000000 --- a/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json,\n tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c" -} diff --git a/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json b/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json deleted file mode 100644 index 1a19f3d..0000000 --- a/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, 42, 12, 3)", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d" -} diff --git a/.sqlx/query-9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3.json b/.sqlx/query-9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3.json new file mode 100644 index 0000000..451eb77 --- /dev/null +++ b/.sqlx/query-9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT project_uri, canonical_path FROM ldraw_resources WHERE resource_uri = ?", + "describe": { + "columns": [ + { + "name": "project_uri", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "project_uri" + } + } + }, + { + "name": "canonical_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "canonical_path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3" +} diff --git a/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json b/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json deleted file mode 100644 index c05c0d6..0000000 --- a/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3" -} diff --git a/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json b/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json deleted file mode 100644 index f4a6b4b..0000000 --- a/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8" -} diff --git a/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json b/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json deleted file mode 100644 index 7ac1386..0000000 --- a/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668" -} diff --git a/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json b/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json deleted file mode 100644 index 8445622..0000000 --- a/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json,\n tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf" -} diff --git a/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json b/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json deleted file mode 100644 index 59db16d..0000000 --- a/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5" -} diff --git a/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json b/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json deleted file mode 100644 index a0eb51e..0000000 --- a/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT COUNT(*) FROM things", - "describe": { - "columns": [ - { - "name": "COUNT(*)", - "ordinal": 0, - "type_info": "Integer", - "origin": "Expression" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false - ] - }, - "hash": "fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c" -} diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_getModelLoadBundle.json b/crates/polymodel-api/lexicons/space_polymodel_library_getModelLoadBundle.json new file mode 100644 index 0000000..aee87fe --- /dev/null +++ b/crates/polymodel-api/lexicons/space_polymodel_library_getModelLoadBundle.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.getModelLoadBundle", + "defs": { + "main": { + "type": "query", + "description": "Resolve a model Part URI into one bounded binary load bundle containing the primary resource and every indexed companion resource required by the format.", + "parameters": { + "type": "params", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + } + } + }, + "output": { + "encoding": "*/*" + } + } + } +} \ No newline at end of file diff --git a/crates/polymodel-api/src/space_polymodel/library.rs b/crates/polymodel-api/src/space_polymodel/library.rs index 7727f44..653e618 100644 --- a/crates/polymodel-api/src/space_polymodel/library.rs +++ b/crates/polymodel-api/src/space_polymodel/library.rs @@ -10,6 +10,7 @@ pub mod delete_thing; pub mod get_author_things; pub mod get_feed; pub mod get_model; +pub mod get_model_load_bundle; pub mod get_part_file; pub mod get_thing; pub mod model; diff --git a/crates/polymodel-api/src/space_polymodel/library/get_model_load_bundle.rs b/crates/polymodel-api/src/space_polymodel/library/get_model_load_bundle.rs new file mode 100644 index 0000000..9d2456d --- /dev/null +++ b/crates/polymodel-api/src/space_polymodel/library/get_model_load_bundle.rs @@ -0,0 +1,195 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: space.polymodel.library.getModelLoadBundle +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::bytes::Bytes; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::AtUri; +use jacquard_common::types::value::Data; +use jacquard_derive::IntoStatic; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetModelLoadBundle { + pub uri: AtUri, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct GetModelLoadBundleOutput { + pub body: Bytes, +} + +/** Response marker for the `space.polymodel.library.getModelLoadBundle` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `GetModelLoadBundleOutput` for this endpoint.*/ +pub struct GetModelLoadBundleResponse; +impl jacquard_common::xrpc::XrpcResp for GetModelLoadBundleResponse { + const NSID: &'static str = "space.polymodel.library.getModelLoadBundle"; + const ENCODING: &'static str = "*/*"; + type Output = GetModelLoadBundleOutput; + type Err = jacquard_common::xrpc::GenericError; + fn encode_output( + output: &Self::Output, + ) -> Result, jacquard_common::xrpc::EncodeError> + where + Self::Output: Serialize, + { + Ok(output.body.to_vec()) + } + fn decode_output<'de, S>( + body: &'de [u8], + ) -> Result, jacquard_common::error::DecodeError> + where + S: BosStr + Deserialize<'de>, + Self::Output: Deserialize<'de>, + { + Ok(GetModelLoadBundleOutput { + body: jacquard_common::deps::bytes::Bytes::copy_from_slice(body), + }) + } +} + +impl jacquard_common::xrpc::XrpcRequest for GetModelLoadBundle { + const NSID: &'static str = "space.polymodel.library.getModelLoadBundle"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetModelLoadBundleResponse; +} + +/** Endpoint marker for the `space.polymodel.library.getModelLoadBundle` query. + +Path: `/xrpc/space.polymodel.library.getModelLoadBundle`. The request payload type is `GetModelLoadBundle`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct GetModelLoadBundleRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetModelLoadBundleRequest { + const PATH: &'static str = "/xrpc/space.polymodel.library.getModelLoadBundle"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = GetModelLoadBundle; + type Response = GetModelLoadBundleResponse; +} + +pub mod get_model_load_bundle_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Uri; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Uri = Unset; + } + ///State transition - sets the `uri` field to Set + pub struct SetUri(PhantomData St>); + impl sealed::Sealed for SetUri {} + impl State for SetUri { + type Uri = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `uri` field + pub struct uri(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct GetModelLoadBundleBuilder< + St: get_model_load_bundle_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: (Option>,), + _type: PhantomData S>, +} + +impl GetModelLoadBundle { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> GetModelLoadBundleBuilder< + get_model_load_bundle_state::Empty, + DefaultStr, + > { + GetModelLoadBundleBuilder::new() + } +} + +impl GetModelLoadBundle { + /// Create a new builder for this type + pub fn builder() -> GetModelLoadBundleBuilder< + get_model_load_bundle_state::Empty, + S, + > { + GetModelLoadBundleBuilder::builder() + } +} + +impl GetModelLoadBundleBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + GetModelLoadBundleBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl GetModelLoadBundleBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + GetModelLoadBundleBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl GetModelLoadBundleBuilder +where + St: get_model_load_bundle_state::State, + St::Uri: get_model_load_bundle_state::IsUnset, +{ + /// Set the `uri` field (required) + pub fn uri( + mut self, + value: impl Into>, + ) -> GetModelLoadBundleBuilder, S> { + self._fields.0 = Option::Some(value.into()); + GetModelLoadBundleBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl GetModelLoadBundleBuilder +where + St: get_model_load_bundle_state::State, + St::Uri: get_model_load_bundle_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> GetModelLoadBundle { + GetModelLoadBundle { + uri: self._fields.0.unwrap(), + } + } +} \ No newline at end of file diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index cca59f5..f817d03 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -22,7 +22,7 @@ pub use load_state::{ }; /// Current wire protocol version. The envelope is mandatory for every postcard message. -pub const PROTOCOL_VERSION: u16 = 1; +pub const PROTOCOL_VERSION: u16 = 2; /// Capability names negotiated before scene-dependent commands are accepted. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -425,38 +425,20 @@ pub fn verify_part_file(bytes: &[u8], size: i64, digest: Option<&[u8]>) -> Resul /// per-frame, never rendering from a single message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum RendererCommand { - /// Begin an interactive load. The main thread allocates the identity and the worker - /// must not allocate or infer one. The worker discovers and fetches glTF companions. + /// Begin a load. The source determines how bytes are obtained; the output determines + /// whether the resulting candidate is installed or rendered once and discarded. StartLoad { namespace: RendererNamespace, identity: LoadIdentity, - format: MeshFormat, - primary_url: String, - expected_size: Option, - expected_digest: Option>, - }, - /// Begin a public, anonymous LDraw load from a complete content-bound plan. - /// The appview re-resolves canonical metadata for every request; the worker - /// never derives companion URLs from filenames. - StartCompoundLoad { - namespace: RendererNamespace, - identity: LoadIdentity, - plan: CompoundLoadPlan, + source: LoadSource, + output: LoadOutput, }, /// Cancel the current identity. Late results are rejected by the reducer. Cancel { namespace: RendererNamespace, identity: LoadIdentity, }, - /// Render a mesh URL to a PNG preview image. This is intentionally handled by - /// the worker so the Dioxus app does not depend on mesh parsing or `three-d`. - RenderPreviewImage { - request_id: u32, - format: MeshFormat, - primary_url: String, - width: u32, - height: u32, - }, + /// Resize the worker's `OffscreenCanvas` and viewport. `css_w`/`css_h` are CSS pixels, /// `dpr` is `window.devicePixelRatio`. The worker sets canvas pixel dimensions to /// `css_w * dpr` × `css_h * dpr`. @@ -479,76 +461,84 @@ pub enum RendererCommand { Dispose, /// Deliberately panic — used by e2e tests to verify crash surfacing. DebugCrash, - /// Render a compound LDraw plan to a temporary PNG preview. Verified bytes and - /// parsed scene data remain inside the worker; only the PNG crosses the bridge. - /// - /// Appended to preserve the discriminants of existing command variants. - StartCompoundPreview { +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum LoadSource { + Url { + format: MeshFormat, + primary_url: String, + expected_size: Option, + expected_digest: Option>, + }, + Bundle(ModelLoadBundle), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LoadOutput { + Interactive, + Preview { request_id: u32, - namespace: RendererNamespace, - identity: LoadIdentity, - plan: CompoundLoadPlan, width: u32, height: u32, }, } -/// One opaque, same-origin resource route supplied by the public appview. -/// The route is stateless and content-bound; the worker never derives routes from -/// filenames or contacts a publisher directly. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkerFetchDescriptor { +pub struct ModelResource { pub key: String, - pub route: String, pub path: String, pub root: String, pub byte_length: u64, pub sha256: Vec, pub mime_type: String, pub target_cids: Vec, + pub bytes: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CompoundLoadPlan { +pub struct ModelLoadBundle { pub version: u16, + pub format: MeshFormat, pub primary: String, - pub descriptors: Vec, + pub resources: Vec, } -pub const MAX_COMPOUND_DESCRIPTORS: usize = 16_384; -pub const MAX_COMPOUND_ENCODED_BYTES: usize = 8 * 1024 * 1024; -pub const MAX_COMPOUND_METADATA_BYTES: usize = 8 * 1024 * 1024; -pub const MAX_COMPOUND_TOTAL_BYTES: u64 = 128 * 1024 * 1024; -pub const MAX_COMPOUND_CIDS_PER_DESCRIPTOR: usize = 4_096; +pub const MAX_MODEL_RESOURCES: usize = 16_384; +pub const MAX_MODEL_BUNDLE_BYTES: usize = 136 * 1024 * 1024; +pub const MAX_MODEL_METADATA_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_MODEL_RESOURCE_BYTES: u64 = 128 * 1024 * 1024; +pub const MAX_MODEL_CIDS_PER_RESOURCE: usize = 4_096; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CompoundPlanError { +pub enum ModelBundleError { Empty, - TooManyDescriptors, + TooManyResources, EncodedBytesExceeded, MetadataExceeded, TotalBytesExceeded, DuplicateKey, PrimaryMissing, - InvalidDescriptor, + InvalidResource, + SizeMismatch, + DigestMismatch, } -impl WorkerFetchDescriptor { - pub fn validate(&self) -> Result<(), &'static str> { - if self.key.is_empty() || self.key != self.path || self.route.is_empty() { - return Err("descriptor identity is incomplete"); - } - if !self.route.starts_with('/') - || self.route.starts_with("//") - || self.route.contains("://") +impl ModelResource { + pub fn validate(&self) -> Result<(), ModelBundleError> { + if self.key.is_empty() + || self.key != self.path + || self.sha256.len() != 32 + || self.mime_type.is_empty() + || self.target_cids.len() > MAX_MODEL_CIDS_PER_RESOURCE { - return Err("descriptor route is not a relative same-origin path"); + return Err(ModelBundleError::InvalidResource); } - if self.sha256.len() != 32 || self.mime_type.is_empty() || self.target_cids.is_empty() { - return Err("descriptor integrity metadata is incomplete"); + if self.byte_length != self.bytes.len() as u64 { + return Err(ModelBundleError::SizeMismatch); } - if self.target_cids.len() > MAX_COMPOUND_CIDS_PER_DESCRIPTOR { - return Err("descriptor has too many target CIDs"); + if sha2::Sha256::digest(&self.bytes)[..] != self.sha256[..] { + return Err(ModelBundleError::DigestMismatch); } Ok(()) } @@ -556,7 +546,6 @@ impl WorkerFetchDescriptor { fn metadata_bytes(&self) -> Option { self.key .len() - .checked_add(self.route.len())? .checked_add(self.path.len())? .checked_add(self.root.len())? .checked_add(self.mime_type.len())? @@ -569,54 +558,63 @@ impl WorkerFetchDescriptor { } } -impl CompoundLoadPlan { - pub fn validate(&self) -> Result<(), CompoundPlanError> { - if self.descriptors.is_empty() { - return Err(CompoundPlanError::Empty); +impl ModelLoadBundle { + pub fn validate(&self) -> Result<(), ModelBundleError> { + if self.resources.is_empty() { + return Err(ModelBundleError::Empty); } - if self.descriptors.len() > MAX_COMPOUND_DESCRIPTORS { - return Err(CompoundPlanError::TooManyDescriptors); + if self.resources.len() > MAX_MODEL_RESOURCES { + return Err(ModelBundleError::TooManyResources); } - let mut keys = HashSet::with_capacity(self.descriptors.len()); + let mut keys = HashSet::with_capacity(self.resources.len()); let mut metadata = self.primary.len(); let mut total_bytes = 0u64; - for descriptor in &self.descriptors { - descriptor - .validate() - .map_err(|_| CompoundPlanError::InvalidDescriptor)?; - if !keys.insert(&descriptor.key) { - return Err(CompoundPlanError::DuplicateKey); + for resource in &self.resources { + resource.validate()?; + if !keys.insert(&resource.key) { + return Err(ModelBundleError::DuplicateKey); } metadata = metadata .checked_add( - descriptor + resource .metadata_bytes() - .ok_or(CompoundPlanError::MetadataExceeded)?, + .ok_or(ModelBundleError::MetadataExceeded)?, ) - .ok_or(CompoundPlanError::MetadataExceeded)?; + .ok_or(ModelBundleError::MetadataExceeded)?; total_bytes = total_bytes - .checked_add(descriptor.byte_length) - .ok_or(CompoundPlanError::TotalBytesExceeded)?; - if metadata > MAX_COMPOUND_METADATA_BYTES { - return Err(CompoundPlanError::MetadataExceeded); + .checked_add(resource.byte_length) + .ok_or(ModelBundleError::TotalBytesExceeded)?; + if metadata > MAX_MODEL_METADATA_BYTES { + return Err(ModelBundleError::MetadataExceeded); } - if total_bytes > MAX_COMPOUND_TOTAL_BYTES { - return Err(CompoundPlanError::TotalBytesExceeded); + if total_bytes > MAX_MODEL_RESOURCE_BYTES { + return Err(ModelBundleError::TotalBytesExceeded); } } if !keys.contains(&self.primary) { - return Err(CompoundPlanError::PrimaryMissing); + return Err(ModelBundleError::PrimaryMissing); } Ok(()) } - pub fn encoded_size(&self) -> Result { + pub fn encode(&self) -> Result, ModelBundleError> { + self.validate()?; let encoded = - postcard::to_allocvec(self).map_err(|_| CompoundPlanError::EncodedBytesExceeded)?; - if encoded.len() > MAX_COMPOUND_ENCODED_BYTES { - return Err(CompoundPlanError::EncodedBytesExceeded); + postcard::to_allocvec(self).map_err(|_| ModelBundleError::EncodedBytesExceeded)?; + if encoded.len() > MAX_MODEL_BUNDLE_BYTES { + return Err(ModelBundleError::EncodedBytesExceeded); + } + Ok(encoded) + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_MODEL_BUNDLE_BYTES { + return Err(ModelBundleError::EncodedBytesExceeded); } - Ok(encoded.len()) + let bundle: Self = + postcard::from_bytes(bytes).map_err(|_| ModelBundleError::InvalidResource)?; + bundle.validate()?; + Ok(bundle) } } @@ -708,19 +706,25 @@ pub fn deserialize_event_envelope(bytes: &[u8]) -> Result Option { +pub fn required_capabilities(command: &RendererCommand) -> Vec { match command { - RendererCommand::StartCompoundLoad { .. } => Some(Capability::CompoundLdraw), - RendererCommand::StartCompoundPreview { .. } => Some(Capability::CompoundLdraw), - RendererCommand::RenderPreviewImage { .. } => Some(Capability::Preview), - RendererCommand::Cancel { .. } => Some(Capability::Cancellation), - RendererCommand::StartLoad { .. } - | RendererCommand::Resize { .. } + RendererCommand::StartLoad { source, output, .. } => { + let mut required = Vec::with_capacity(2); + if matches!(source, LoadSource::Bundle(_)) { + required.push(Capability::CompoundLdraw); + } + if matches!(output, LoadOutput::Preview { .. }) { + required.push(Capability::Preview); + } + required + } + RendererCommand::Cancel { .. } => vec![Capability::Cancellation], + RendererCommand::Resize { .. } | RendererCommand::SetPointerState { .. } | RendererCommand::PointerDown { .. } | RendererCommand::PointerUp { .. } | RendererCommand::Dispose - | RendererCommand::DebugCrash => None, + | RendererCommand::DebugCrash => Vec::new(), } } @@ -731,6 +735,7 @@ pub fn required_capability(command: &RendererCommand) -> Option { #[cfg(test)] mod tests { use super::*; + use sha2::Sha256; #[test] fn verify_part_file_matching_size_and_digest_is_ok() { @@ -845,94 +850,92 @@ mod tests { } } - fn compound_descriptor(key: &str, route: &str, byte: u8) -> WorkerFetchDescriptor { - WorkerFetchDescriptor { + fn model_resource(key: &str, byte: u8) -> ModelResource { + let bytes = vec![byte]; + ModelResource { key: key.into(), - route: route.into(), path: key.into(), root: "models".into(), - byte_length: 1, - sha256: vec![byte; 32], + byte_length: bytes.len() as u64, + sha256: Sha256::digest(&bytes).to_vec(), mime_type: "text/plain".into(), target_cids: vec![format!("cid-{byte}")], + bytes, } } - fn compound_plan() -> CompoundLoadPlan { - CompoundLoadPlan { + fn model_bundle() -> ModelLoadBundle { + ModelLoadBundle { version: PROTOCOL_VERSION, + format: MeshFormat::CompoundLdraw, primary: "models/main.ldr".into(), - descriptors: vec![ - compound_descriptor("models/main.ldr", "/app/ldraw/a", 1), - compound_descriptor("models/child.dat", "/app/ldraw/b", 2), + resources: vec![ + model_resource("models/main.ldr", 1), + model_resource("models/child.dat", 2), ], } } + fn url_source(format: MeshFormat, url: &str) -> LoadSource { + LoadSource::Url { + format, + primary_url: url.into(), + expected_size: None, + expected_digest: None, + } + } + #[test] fn all_commands_roundtrip() { let commands = vec![ RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), - format: MeshFormat::Stl, - primary_url: "/models/cube.stl".into(), - expected_size: Some(4096), - expected_digest: Some(vec![0u8; 32]), + source: url_source(MeshFormat::Stl, "/models/cube.stl"), + output: LoadOutput::Interactive, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 2), - format: MeshFormat::Obj, - primary_url: "/models/cube.obj".into(), - expected_size: None, - expected_digest: None, + source: url_source(MeshFormat::Obj, "/models/cube.obj"), + output: LoadOutput::Interactive, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 1), - format: MeshFormat::Gltf, - primary_url: "/models/cube.glb".into(), - expected_size: Some(2048), - expected_digest: None, + source: url_source(MeshFormat::Gltf, "/models/cube.glb"), + output: LoadOutput::Interactive, }, RendererCommand::StartLoad { - namespace: RendererNamespace::Interactive, + namespace: RendererNamespace::Preview, identity: LoadIdentity::new(2, 2), - format: MeshFormat::Threemf, - primary_url: "/models/cube.3mf".into(), - expected_size: None, - expected_digest: Some(vec![1u8; 32]), + source: url_source(MeshFormat::Threemf, "/models/cube.3mf"), + output: LoadOutput::Preview { + request_id: 7, + width: 1024, + height: 1024, + }, }, - RendererCommand::StartCompoundLoad { + RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(3, 1), - plan: compound_plan(), + source: LoadSource::Bundle(model_bundle()), + output: LoadOutput::Interactive, }, - RendererCommand::StartCompoundLoad { + RendererCommand::StartLoad { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(3, 2), - plan: compound_plan(), - }, - RendererCommand::StartCompoundPreview { - request_id: 8, - namespace: RendererNamespace::Preview, - identity: LoadIdentity::new(3, 3), - plan: compound_plan(), - width: 640, - height: 480, + source: LoadSource::Bundle(model_bundle()), + output: LoadOutput::Preview { + request_id: 8, + width: 640, + height: 480, + }, }, RendererCommand::Cancel { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 2), }, - RendererCommand::RenderPreviewImage { - request_id: 7, - format: MeshFormat::Stl, - primary_url: "blob:http://localhost/preview".into(), - width: 1024, - height: 1024, - }, RendererCommand::Resize { css_w: 800.0, css_h: 600.0, @@ -959,37 +962,22 @@ mod tests { } #[test] - fn compound_preview_requires_compound_capability() { - let command = RendererCommand::StartCompoundPreview { - request_id: 91, + fn bundled_preview_requires_bundle_and_preview_capabilities() { + let command = RendererCommand::StartLoad { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(7, 4), - plan: compound_plan(), - width: 320, - height: 240, + source: LoadSource::Bundle(model_bundle()), + output: LoadOutput::Preview { + request_id: 91, + width: 320, + height: 240, + }, }; assert_eq!( - required_capability(&command), - Some(Capability::CompoundLdraw) + required_capabilities(&command), + vec![Capability::CompoundLdraw, Capability::Preview] ); - let encoded = roundtrip_command(&command); - assert_eq!(encoded, command); - if let RendererCommand::StartCompoundPreview { - request_id, - namespace, - identity, - width, - height, - .. - } = encoded - { - assert_eq!(request_id, 91); - assert_eq!(namespace, RendererNamespace::Preview); - assert_eq!(identity, LoadIdentity::new(7, 4)); - assert_eq!((width, height), (320, 240)); - } else { - panic!("compound preview command changed during roundtrip"); - } + assert_eq!(roundtrip_command(&command), command); } #[test] @@ -1077,15 +1065,15 @@ mod tests { #[test] fn handshake_rejects_pre_negotiation_and_stale_sessions() { let hello = ProtocolHello { - min_version: 1, - max_version: 1, + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, required: vec![Capability::CurrentFormats], optional: vec![Capability::Preview], }; let negotiated = negotiate(&hello, &hello, 44).unwrap(); let mut state = HandshakeState::Created; let command = CommandEnvelope { - version: 1, + version: PROTOCOL_VERSION, session_id: 44, payload: CommandPayload::Command(RendererCommand::Dispose), }; @@ -1163,32 +1151,7 @@ mod tests { payload: CommandPayload::Command(RendererCommand::Dispose), }; let bytes = serialize_command_envelope(&envelope).unwrap(); - assert_eq!(bytes, [1, 7, 2, 8]); - } - - #[test] - fn independent_golden_compound_preview_envelope_bytes_remain_stable() { - let envelope = CommandEnvelope { - version: PROTOCOL_VERSION, - session_id: 7, - payload: CommandPayload::Command(RendererCommand::StartCompoundPreview { - request_id: 8, - namespace: RendererNamespace::Preview, - identity: LoadIdentity::new(3, 3), - plan: CompoundLoadPlan { - version: PROTOCOL_VERSION, - primary: "p".into(), - descriptors: vec![], - }, - width: 640, - height: 480, - }), - }; - let bytes = serialize_command_envelope(&envelope).unwrap(); - assert_eq!( - bytes, - [1, 7, 2, 10, 8, 1, 3, 3, 1, 1, 112, 0, 128, 5, 224, 3] - ); + assert_eq!(bytes, [2, 7, 2, 6]); } #[test] @@ -1199,56 +1162,31 @@ mod tests { payload: EventPayload::Event(RendererEvent::ContextAcquired), }; let bytes = serialize_event_envelope(&envelope).unwrap(); - assert_eq!(bytes, [1, 7, 3, 0]); + assert_eq!(bytes, [2, 7, 3, 0]); } #[test] - fn descriptor_rejects_absolute_and_incomplete_routes() { - let mut descriptor = WorkerFetchDescriptor { - key: "models/main.ldr".into(), - route: "/app/route/opaque".into(), - path: "models/main.ldr".into(), - root: "models".into(), - byte_length: 1, - sha256: vec![0; 32], - mime_type: "text/plain".into(), - target_cids: vec!["cid".into()], - }; - assert!(descriptor.validate().is_ok()); - descriptor.route = "https://publisher.example/file".into(); - assert!(descriptor.validate().is_err()); + fn model_bundle_rejects_tampered_bytes_and_declared_size() { + let mut bundle = model_bundle(); + bundle.resources[0].bytes[0] ^= 0xff; + assert_eq!(bundle.validate(), Err(ModelBundleError::DigestMismatch)); + + let mut bundle = model_bundle(); + bundle.resources[0].byte_length += 1; + assert_eq!(bundle.validate(), Err(ModelBundleError::SizeMismatch)); } #[test] - fn compound_plan_bounds_cover_aggregate_bytes_and_metadata() { - let mut plan = compound_plan(); - plan.descriptors[0].byte_length = MAX_COMPOUND_TOTAL_BYTES; - assert_eq!(plan.validate(), Err(CompoundPlanError::TotalBytesExceeded)); - - let mut plan = compound_plan(); - plan.descriptors[0].target_cids = vec!["cid".repeat(MAX_COMPOUND_METADATA_BYTES)]; - assert_eq!(plan.validate(), Err(CompoundPlanError::MetadataExceeded)); - } + fn model_bundle_roundtrips_and_rejects_duplicate_keys() { + let bundle = model_bundle(); + assert_eq!( + ModelLoadBundle::decode(&bundle.encode().unwrap()), + Ok(bundle.clone()) + ); - #[test] - fn compound_plan_rejects_oversized_descriptor_lists_and_duplicate_keys() { - let descriptor = compound_descriptor("models/main.ldr", "/app/ldraw/a", 1); - let mut plan = CompoundLoadPlan { - version: PROTOCOL_VERSION, - primary: "models/main.ldr".into(), - descriptors: vec![descriptor.clone(), descriptor], - }; - assert_eq!(plan.validate(), Err(CompoundPlanError::DuplicateKey)); - plan.descriptors = (0..=MAX_COMPOUND_DESCRIPTORS) - .map(|index| { - compound_descriptor( - &format!("models/{index}.dat"), - &format!("/app/ldraw/{index}"), - 1, - ) - }) - .collect(); - assert_eq!(plan.validate(), Err(CompoundPlanError::TooManyDescriptors)); + let mut duplicate = bundle; + duplicate.resources.push(duplicate.resources[0].clone()); + assert_eq!(duplicate.validate(), Err(ModelBundleError::DuplicateKey)); } #[test] diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index d25eef7..80c42f4 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -94,7 +94,7 @@ struct PreviewTarget { struct VerifiedCompoundSource { primary: String, - descriptors: Vec<(polymodel_renderer_protocol::WorkerFetchDescriptor, Vec)>, + resources: Vec, } fn descriptor_root(root: &str) -> Result { @@ -107,31 +107,31 @@ fn descriptor_root(root: &str) -> Result { } } -fn validate_compound_descriptors( +fn validate_bundle_resources( primary: &str, - descriptors: &[polymodel_renderer_protocol::WorkerFetchDescriptor], + resources: &[polymodel_renderer_protocol::ModelResource], ) -> Result<(), String> { - let mut normalized = HashSet::with_capacity(descriptors.len()); + let mut normalized = HashSet::with_capacity(resources.len()); let mut primary_found = false; - for descriptor in descriptors { - descriptor + for resource in resources { + resource .validate() - .map_err(|error| format!("compound descriptor rejected: {error}"))?; - let path = NormalizedPath::new(&descriptor.path).map_err(|error| error.to_string())?; - let root = descriptor_root(&descriptor.root)?; + .map_err(|error| format!("model resource rejected: {error:?}"))?; + let path = NormalizedPath::new(&resource.path).map_err(|error| error.to_string())?; + let root = descriptor_root(&resource.root)?; let identity = format!("{root:?}:{}", path.as_str()); if !normalized.insert(identity) { return Err(format!( - "duplicate normalized descriptor identity: {}", - descriptor.path + "duplicate normalized resource identity: {}", + resource.path )); } - if descriptor.path == primary { + if resource.path == primary { primary_found = true; } } if !primary_found { - return Err("compound primary descriptor missing".to_owned()); + return Err("model bundle primary resource missing".to_owned()); } Ok(()) } @@ -148,25 +148,25 @@ fn parse_compound_source( String, > { let primary = source - .descriptors + .resources .iter() - .find(|(descriptor, _)| descriptor.path == source.primary) - .ok_or_else(|| "compound primary descriptor missing".to_string())?; - let primary_root = descriptor_root(&primary.0.root)?; + .find(|resource| resource.path == source.primary) + .ok_or_else(|| "model bundle primary resource missing".to_string())?; + let primary_root = descriptor_root(&primary.root)?; let mut materializations = Vec::new(); - for (descriptor, bytes) in &source.descriptors { - let path = NormalizedPath::new(&descriptor.path).map_err(|error| error.to_string())?; + for resource in &source.resources { + let path = NormalizedPath::new(&resource.path).map_err(|error| error.to_string())?; materializations.push(Materialization::new( path, - descriptor_root(&descriptor.root)?, - bytes.clone(), + descriptor_root(&resource.root)?, + resource.bytes.clone(), true, - Some(format!("worker:{}", descriptor.key)), + Some(format!("worker:{}", resource.key)), )); } let result = InProcessRustAdapter::parse(AdapterRequest { fixture_id: &source.primary, - bytes: &primary.1, + bytes: &primary.bytes, profile: ParserProfile::Compatibility, provenance_id: &source.primary, owner, @@ -249,7 +249,6 @@ struct WorkerState { canvas_reservation: Option, load_epoch: u32, scene_generation: u32, - preview_generation: u32, interactive_compound_abort: Option, preview_abort: Option, pending_events: Vec, @@ -278,7 +277,6 @@ impl WorkerState { canvas_reservation: None, load_epoch: 0, scene_generation: 0, - preview_generation: 0, interactive_compound_abort: None, preview_abort: None, pending_events: Vec::new(), @@ -1599,11 +1597,50 @@ async fn load_mesh_async( } // --------------------------------------------------------------------------- +fn start_compound_interactive( + state: &Rc>, + namespace: RendererNamespace, + identity: LoadIdentity, + bundle: polymodel_renderer_protocol::ModelLoadBundle, +) { + let mut st = state.borrow_mut(); + if st.disposed || namespace != RendererNamespace::Interactive { + return; + } + let previous_identity = st.load_reducer.current_identity(); + let result = st.load_reducer.reduce(LoadEvent::StartLoad { + namespace, + identity, + }); + for _token in result.disposed { + st.pending_scene = None; + } + if !matches!( + result.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + st.pending_scene = None; + st.needs_render = true; + if let Some(previous_identity) = previous_identity + && identity > previous_identity + { + abort_compound_slot(&mut st, namespace, previous_identity); + st.post_event(&RendererEvent::LoadCancelled { + namespace, + identity: previous_identity, + }); + } + drop(st); + handle_compound_load(state, namespace, identity, bundle); +} + fn handle_compound_load( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, - plan: polymodel_renderer_protocol::CompoundLoadPlan, + bundle: polymodel_renderer_protocol::ModelLoadBundle, ) { let abort = AbortController::new().ok(); if let Some(abort) = &abort { @@ -1622,7 +1659,7 @@ fn handle_compound_load( } let state_clone = state.clone(); spawn_local(async move { - let result = load_compound_source(&state_clone, namespace, identity, plan, abort).await; + let result = load_compound_source(&state_clone, namespace, identity, bundle, abort).await; finish_compound_source(&state_clone, namespace, identity, result); }); } @@ -1632,7 +1669,7 @@ fn handle_compound_preview( request_id: u32, namespace: RendererNamespace, identity: LoadIdentity, - plan: polymodel_renderer_protocol::CompoundLoadPlan, + bundle: polymodel_renderer_protocol::ModelLoadBundle, width: u32, height: u32, ) { @@ -1686,7 +1723,7 @@ fn handle_compound_preview( let result = render_compound_preview_request( &state_clone, identity, - plan, + bundle, width.max(1), height.max(1), abort.clone(), @@ -1759,7 +1796,7 @@ fn finish_compound_preview( async fn render_compound_preview_request( state: &Rc>, identity: LoadIdentity, - plan: polymodel_renderer_protocol::CompoundLoadPlan, + bundle: polymodel_renderer_protocol::ModelLoadBundle, width: u32, height: u32, abort: Option, @@ -1768,7 +1805,7 @@ async fn render_compound_preview_request( state, RendererNamespace::Preview, identity, - plan, + bundle, abort.clone(), ) .await?; @@ -1854,58 +1891,45 @@ async fn load_compound_source( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, - plan: polymodel_renderer_protocol::CompoundLoadPlan, + bundle: polymodel_renderer_protocol::ModelLoadBundle, abort: Option, ) -> Result { - if plan.version != polymodel_renderer_protocol::PROTOCOL_VERSION { - return Err("compound plan protocol version mismatch".into()); - } - plan.validate() - .map_err(|error| format!("compound plan rejected: {error:?}"))?; - plan.encoded_size() - .map_err(|error| format!("compound plan encoding rejected: {error:?}"))?; - validate_compound_descriptors(&plan.primary, &plan.descriptors)?; + if bundle.version != polymodel_renderer_protocol::PROTOCOL_VERSION { + return Err("model bundle protocol version mismatch".into()); + } + if bundle.format != MeshFormat::CompoundLdraw { + return Err("model bundle format is not LDraw".into()); + } + bundle + .validate() + .map_err(|error| format!("model bundle rejected: {error:?}"))?; + validate_bundle_resources(&bundle.primary, &bundle.resources)?; compound_is_current(state, namespace, identity, abort.as_ref())?; - let descriptors = plan.descriptors; - let owner = load_owner(namespace, identity); - let total_bytes = descriptors.iter().try_fold(0_u64, |total, descriptor| { + let total_bytes = bundle.resources.iter().try_fold(0_u64, |total, resource| { total - .checked_add(descriptor.byte_length) - .ok_or("compound plan byte bound overflow") + .checked_add(resource.byte_length) + .ok_or("model bundle byte bound overflow") })?; if total_bytes > MAX_UNKNOWN_FETCH_BYTES { - return Err("compound plan exceeds fetch bound".into()); + return Err("model bundle exceeds decoded source bound".into()); } let reservation = { let st = state.borrow(); st.ledger .composite( - owner, + load_owner(namespace, identity), &[ - (ResourceClass::NetworkInFlight, total_bytes), (ResourceClass::RetainedFetchedBytes, total_bytes), (ResourceClass::DecodedSourceData, total_bytes), (ResourceClass::WasmPages, WASM_FIXED_BYTES), ], ) - .map_err(|error| format!("compound load admission: {error}"))? + .map_err(|error| format!("model bundle admission: {error}"))? }; - let mut fetched = Vec::with_capacity(descriptors.len()); - for descriptor in descriptors { - compound_is_current(state, namespace, identity, abort.as_ref())?; - let bytes = fetch_descriptor_bytes( - &descriptor.route, - descriptor.byte_length, - &descriptor.sha256, - abort.as_ref(), - ) - .await?; - fetched.push((descriptor, bytes)); - } compound_is_current(state, namespace, identity, abort.as_ref())?; let source = VerifiedCompoundSource { - primary: plan.primary, - descriptors: fetched, + primary: bundle.primary, + resources: bundle.resources, }; drop(reservation); Ok(source) @@ -2155,41 +2179,17 @@ fn finish_compound_source( } } -async fn fetch_descriptor_bytes( - route: &str, - expected_length: u64, - expected_sha256: &[u8], - abort: Option<&AbortController>, -) -> Result, String> { - let bytes = fetch_bytes(route, abort, Some(expected_length)).await?; - let expected_size = i64::try_from(expected_length) - .map_err(|_| "compound descriptor byte length is too large".to_string())?; - polymodel_renderer_protocol::verify_part_file(&bytes, expected_size, Some(expected_sha256)) - .map_err(|error| format!("compound descriptor integrity check failed: {error}"))?; - Ok(bytes) -} - fn handle_render_preview_image( state: &Rc>, request_id: u32, + identity: LoadIdentity, format: MeshFormat, primary_url: String, width: u32, height: u32, ) { - let identity = { + let abort = { let mut st = state.borrow_mut(); - st.preview_generation = match next_generation(st.preview_generation) { - Ok(generation) => generation, - Err(message) => { - st.post_event(&RendererEvent::PreviewImageError { - request_id, - message: message.to_owned(), - }); - return; - } - }; - let identity = LoadIdentity::new(st.load_epoch, st.preview_generation); let previous = st.preview_reducer.current_identity(); let transition = st.preview_reducer.reduce(LoadEvent::StartLoad { namespace: RendererNamespace::Preview, @@ -2218,9 +2218,8 @@ fn handle_render_preview_image( identity: previous, }); } - (identity, abort) + abort }; - let (identity, abort) = identity; let state_clone = state.clone(); spawn_local(async move { let result = render_preview_request( @@ -2585,10 +2584,9 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { tracing::warn!("rejecting scene command before negotiated session"); return; }; - let capability_allowed = polymodel_renderer_protocol::required_capability(&cmd) - .is_none_or(|capability| capabilities.contains(&capability)) - && (!matches!(cmd, RendererCommand::StartCompoundPreview { .. }) - || capabilities.contains(&Capability::Preview)); + let capability_allowed = polymodel_renderer_protocol::required_capabilities(&cmd) + .iter() + .all(|capability| capabilities.contains(capability)); if !capability_allowed { tracing::warn!("renderer command rejected by negotiated capabilities"); return; @@ -2598,92 +2596,63 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { RendererCommand::StartLoad { namespace, identity, - format, - primary_url, - expected_size, - expected_digest, + source, + output, } => { drop(st); - handle_load_mesh( - state, - namespace, - identity, - format, - primary_url, - expected_size, - expected_digest, - ); - } - RendererCommand::StartCompoundPreview { - request_id, - namespace, - identity, - plan, - width, - height, - } => { - drop(st); - handle_compound_preview(state, request_id, namespace, identity, plan, width, height); - } - RendererCommand::StartCompoundLoad { - namespace, - identity, - plan, - } => { - if st.disposed { - return; - } - if namespace == RendererNamespace::Preview { - st.post_event(&RendererEvent::LoadFailed { + match (source, output) { + ( + polymodel_renderer_protocol::LoadSource::Url { + format, + primary_url, + expected_size, + expected_digest, + }, + polymodel_renderer_protocol::LoadOutput::Interactive, + ) => handle_load_mesh( + state, namespace, identity, - code: LoadErrorCode::Install, - detail: "compound Preview requires StartCompoundPreview".to_owned(), - }); - return; - } - let reducer = match namespace { - RendererNamespace::Interactive => &mut st.load_reducer, - RendererNamespace::Preview => &mut st.preview_reducer, - }; - let previous_identity = reducer.current_identity(); - let result = reducer.reduce(LoadEvent::StartLoad { - namespace, - identity, - }); - if namespace == RendererNamespace::Interactive { - for _token in result.disposed { - st.pending_scene = None; - st.renderer.as_mut().map(|renderer| renderer.model = None); - } - if !matches!( - result.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { - return; - } - if let Some(renderer) = st.renderer.as_mut() { - renderer.model = None; - } - st.pending_scene = None; - st.needs_render = true; - } else if !matches!( - result.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { - return; - } - if let Some(previous_identity) = previous_identity - && identity > previous_identity - { - abort_compound_slot(&mut st, namespace, previous_identity); - st.post_event(&RendererEvent::LoadCancelled { - namespace, - identity: previous_identity, - }); + format, + primary_url, + expected_size, + expected_digest, + ), + ( + polymodel_renderer_protocol::LoadSource::Url { + format, + primary_url, + .. + }, + polymodel_renderer_protocol::LoadOutput::Preview { + request_id, + width, + height, + }, + ) => handle_render_preview_image( + state, + request_id, + identity, + format, + primary_url, + width, + height, + ), + ( + polymodel_renderer_protocol::LoadSource::Bundle(bundle), + polymodel_renderer_protocol::LoadOutput::Interactive, + ) => start_compound_interactive(state, namespace, identity, bundle), + ( + polymodel_renderer_protocol::LoadSource::Bundle(bundle), + polymodel_renderer_protocol::LoadOutput::Preview { + request_id, + width, + height, + }, + ) => handle_compound_preview( + state, request_id, namespace, identity, bundle, width, height, + ), } - drop(st); - handle_compound_load(state, namespace, identity, plan); } RendererCommand::Cancel { namespace, @@ -2715,28 +2684,12 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { - if namespace == RendererNamespace::Interactive { - if let Some(renderer) = st.renderer.as_mut() { - renderer.model = None; - } - st.needs_render = true; - } st.post_event(&RendererEvent::LoadCancelled { namespace, identity, }); } } - RendererCommand::RenderPreviewImage { - request_id, - format, - primary_url, - width, - height, - } => { - drop(st); - handle_render_preview_image(state, request_id, format, primary_url, width, height); - } RendererCommand::Resize { css_w, css_h, dpr } => { if let Some(slot) = st.preview_abort.take() { slot.controller.abort(); diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index 273cd96..4ac2002 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -97,41 +97,39 @@ test('direct viewer routes select deterministic STL and 3MF assets', async ({ pa await expect(page.getByRole('button', { name: 'Body F forearm 2x v9' })).toBeVisible(); }); -test('logged-out Thing detail acquires the compound MPD through the public plan and resource routes', async ({ page }) => { - const resourceResponses = []; - page.on('response', (response) => { - if (response.url().includes('/app/ldraw/resources/')) resourceResponses.push(response); +test('logged-out Thing detail acquires the complete model bundle in one public XRPC request', async ({ page }) => { + const bundleRequests = []; + const resourceRequests = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if ( + url.pathname === '/xrpc/space.polymodel.library.getModelLoadBundle' + && request.method() === 'GET' + ) { + bundleRequests.push(request); + } + if (url.pathname.startsWith('/app/ldraw/resources/')) resourceRequests.push(request); }); - const planResponsePromise = page.waitForResponse((response) => - response.url().endsWith('/app/ldraw/compound-resources') && response.request().method() === 'POST', + const bundleResponsePromise = page.waitForResponse((response) => + response.url().includes('/xrpc/space.polymodel.library.getModelLoadBundle') + && response.request().method() === 'GET', ); await page.goto(sampleThingPath); - const planResponse = await planResponsePromise; - expect(planResponse.status()).toBe(200); - const plan = await planResponse.json(); - expect(plan.primary).toBe('models/main.ldr'); - expect(plan.descriptors.map((descriptor) => descriptor.key)).toEqual([ - 'models/main.ldr', - 'models/child.dat', - 'models/companion.dat', - ]); - expect(planResponse.request().postDataJSON()).toEqual({ - project: 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.thing/parametric-enclosure', - path: 'models/main.ldr', - }); - - await expect.poll(() => resourceResponses.length).toBe(3); - for (const response of resourceResponses) expect(response.status()).toBe(200); - for (const descriptor of plan.descriptors) { - const first = await page.request.get(new URL(descriptor.route, page.url()).toString()); - const second = await page.request.get(new URL(descriptor.route, page.url()).toString()); - expect(first.status()).toBe(200); - expect(second.status()).toBe(200); - expect(await first.body()).toEqual(await second.body()); - } + const bundleResponse = await bundleResponsePromise; + expect(bundleResponse.status()).toBe(200); + expect(bundleResponse.headers()['content-type']).toContain('application/vnd.polymodel.load-bundle'); + expect((await bundleResponse.body()).byteLength).toBeGreaterThan(0); + expect(new URL(bundleResponse.url()).searchParams.get('uri')).toBe( + 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.part/part-01', + ); await expect(page.locator(boundary)).toHaveAttribute('data-compound-boundary', 'true'); + expect(bundleRequests).toHaveLength(1); + expect(new URL(bundleRequests[0].url()).searchParams.get('uri')).toBe( + 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.part/part-01', + ); + expect(resourceRequests).toHaveLength(0); }); test('model switching keeps one WebGL context and updates renderer session metadata', async ({ page }) => { diff --git a/lexicons/library/getModelLoadBundle.json b/lexicons/library/getModelLoadBundle.json new file mode 100644 index 0000000..19c3096 --- /dev/null +++ b/lexicons/library/getModelLoadBundle.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.getModelLoadBundle", + "defs": { + "main": { + "type": "query", + "description": "Resolve a model Part URI into one bounded binary load bundle containing the primary resource and every indexed companion resource required by the format.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "at-uri" } + } + }, + "output": { + "encoding": "*/*" + } + } + } +} diff --git a/src/appview/download_tests.rs b/src/appview/download_tests.rs index cf5764e..87d6350 100644 --- a/src/appview/download_tests.rs +++ b/src/appview/download_tests.rs @@ -240,7 +240,7 @@ async fn get_part_file_later_blob_failure_truncates_committed_stream() { } #[tokio::test] -async fn anonymous_compound_surface_is_present_without_legacy_routes_or_route_rows() { +async fn anonymous_model_bundle_surface_is_present_without_legacy_routes_or_route_rows() { let state = state().await; let pool = state.pool.clone(); let app = crate::appview::router().with_state(state); @@ -250,10 +250,10 @@ async fn anonymous_compound_surface_is_present_without_legacy_routes_or_route_ro .oneshot( Request::builder() .method(Method::POST) - .uri("/app/ldraw/compound-resources") + .uri("/app/model-load-bundle") .header("content-type", "application/json") .body(Body::from( - r#"{"project":"at://did:plc:x/space.polymodel.library.thing/main","path":"models/main.ldr"}"#, + r#"{"uri":"at://did:plc:x/space.polymodel.library.part/main"}"#, )) .unwrap(), ) diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index 43b9fc4..23c472d 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -6,15 +6,19 @@ use base64::Engine as _; use futures::TryStreamExt; use futures::stream; use jacquard::identity::resolver::IdentityResolver; +use jacquard_axum::ExtractXrpc; use jacquard_common::types::string::{AtUri, Cid, Did}; use jacquard_common::xrpc::XrpcExt; use polymodel_api::com_atproto::sync::get_blob::GetBlob; +use polymodel_api::space_polymodel::library::get_model_load_bundle::GetModelLoadBundleRequest; use polymodel_ldraw_core::extract_references; use polymodel_renderer_protocol::{ - CompoundLoadPlan, MAX_COMPOUND_DESCRIPTORS, MAX_COMPOUND_METADATA_BYTES, - MAX_COMPOUND_TOTAL_BYTES, PROTOCOL_VERSION, WorkerFetchDescriptor, + MAX_MODEL_METADATA_BYTES, MAX_MODEL_RESOURCE_BYTES, MAX_MODEL_RESOURCES, MeshFormat, + ModelLoadBundle, ModelResource, PROTOCOL_VERSION, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +#[cfg(test)] +use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::{HashSet, VecDeque}; @@ -25,7 +29,7 @@ use crate::ldraw::{CanonicalPath, LdrawResourceResolver, RootId}; const ROUTE_PREFIX: &str = "/app/ldraw/resources/"; const ROUTE_VERSION: u8 = 1; -const MAX_MANIFEST_EDGES: usize = MAX_COMPOUND_DESCRIPTORS * 8; +const MAX_MANIFEST_EDGES: usize = MAX_MODEL_RESOURCES * 8; #[derive(Debug)] struct ManifestEntry { @@ -39,13 +43,12 @@ struct ManifestEntry { } #[derive(Debug, Deserialize)] -pub(super) struct CompoundRequest { - pub project: AtUri, - pub path: String, +pub(super) struct ModelBundleRequest { + pub uri: AtUri, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct RouteBinding { +pub(super) struct RouteBinding { version: u8, project: AtUri, publisher: Did, @@ -57,6 +60,7 @@ struct RouteBinding { sha256: Vec, } +#[cfg(test)] #[derive(Debug, Serialize)] struct RoutePayload<'a> { v: u8, @@ -83,12 +87,30 @@ struct RoutePayloadOwned { sha256: Vec, } -pub(super) async fn compound_resources( +pub(super) async fn get_model_load_bundle( State(state): State, - axum::Json(request): axum::Json, -) -> AppResult> { - let path = CanonicalPath::parse(&request.path) - .map_err(|diagnostic| invalid_request(format!("invalid LDraw path: {diagnostic:?}")))?; + ExtractXrpc(request): ExtractXrpc, +) -> AppResult { + build_model_load_bundle(state, request.uri).await +} + +pub(super) async fn app_model_load_bundle( + State(state): State, + axum::Json(request): axum::Json, +) -> AppResult { + build_model_load_bundle(state, request.uri).await +} + +async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult { + let row = db(sqlx::query!( + "SELECT project_uri, canonical_path FROM ldraw_resources WHERE resource_uri = ?", + uri.as_ref(), + ) + .fetch_optional(&state.pool) + .await)? + .ok_or_else(not_found)?; + let project = AtUri::new_owned(row.project_uri).map_err(|_| not_found())?; + let path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; let resolver = SqliteResolver::new(state.pool.clone()); let mut resources = Vec::new(); let mut seen_bindings = HashSet::new(); @@ -98,20 +120,20 @@ pub(super) async fn compound_resources( while let Some(current_path) = queue.pop_front() { let current = resolver - .resolve(&request.project, ¤t_path) + .resolve(&project, ¤t_path) .await .map_err(resolve_error)?; - let current_binding = binding_for(&request.project, ¤t)?; + let current_binding = binding_for(&project, ¤t)?; if !seen_bindings.insert(current_binding.clone()) { continue; } - if seen_bindings.len() > MAX_COMPOUND_DESCRIPTORS { + if seen_bindings.len() > MAX_MODEL_RESOURCES { return Err(invalid_request( "compound resource descriptor bound exceeded", )); } let projected_length = current_binding.byte_length; - let remaining_budget = MAX_COMPOUND_TOTAL_BYTES + let remaining_budget = MAX_MODEL_RESOURCE_BYTES .checked_sub(total_bytes) .ok_or_else(|| invalid_request("compound byte bound overflow"))?; if projected_length > remaining_budget { @@ -158,7 +180,7 @@ pub(super) async fn compound_resources( } let child_path = core_reference_path(¤t.path, reference).ok_or_else(not_found)?; let child = resolver - .resolve(&request.project, &child_path) + .resolve(&project, &child_path) .await .map_err(resolve_error)?; validate_manifest_child(¤t, &child, &manifest)?; @@ -177,73 +199,79 @@ pub(super) async fn compound_resources( return Err(invalid_request("compound manifest edge bound exceeded")); } let child = resolver - .resolve(&request.project, &entry.path) + .resolve(&project, &entry.path) .await .map_err(resolve_error)?; push_first_seen(&mut children, &mut referenced_paths, child.path); } } - resources.push(current); + resources.push((current, bytes)); for child_path in children { queue.push_back(child_path); } } - let mut descriptors = Vec::with_capacity(resources.len()); + let mut bundled = Vec::with_capacity(resources.len()); let mut seen_keys = HashSet::new(); let mut metadata_bytes = path.as_str().len(); - for resource in resources { - let binding = binding_for(&request.project, &resource)?; - let route = encode_binding(&binding)?; + for (resource, bytes) in resources { + let binding = binding_for(&project, &resource)?; let key = resource.path.to_string(); if !seen_keys.insert(key.clone()) { continue; } - let descriptor = WorkerFetchDescriptor { + let bundled_resource = ModelResource { key: key.clone(), - route, path: key, root: resource.root.as_str().to_owned(), byte_length: binding.byte_length, sha256: binding.sha256, mime_type: resource.mime_type, target_cids: binding.target_cids, + bytes, }; - descriptor + bundled_resource .validate() - .map_err(|error| invalid_request(format!("invalid compound descriptor: {error}")))?; - let descriptor_metadata = descriptor + .map_err(|error| invalid_request(format!("invalid model resource: {error:?}")))?; + let resource_metadata = bundled_resource .key .len() - .checked_add(descriptor.route.len()) - .and_then(|size| size.checked_add(descriptor.path.len())) - .and_then(|size| size.checked_add(descriptor.root.len())) - .and_then(|size| size.checked_add(descriptor.mime_type.len())) + .checked_add(bundled_resource.path.len()) + .and_then(|size| size.checked_add(bundled_resource.root.len())) + .and_then(|size| size.checked_add(bundled_resource.mime_type.len())) .and_then(|size| { - descriptor + bundled_resource .target_cids .iter() .try_fold(size, |total, cid| total.checked_add(cid.len())) }) - .ok_or_else(|| invalid_request("compound metadata bound overflow"))?; + .ok_or_else(|| invalid_request("model bundle metadata bound overflow"))?; metadata_bytes = metadata_bytes - .checked_add(descriptor_metadata) - .ok_or_else(|| invalid_request("compound metadata bound overflow"))?; - descriptors.push(descriptor); + .checked_add(resource_metadata) + .ok_or_else(|| invalid_request("model bundle metadata bound overflow"))?; + bundled.push(bundled_resource); } - if metadata_bytes > MAX_COMPOUND_METADATA_BYTES { - return Err(invalid_request("compound plan bounds exceeded")); + if metadata_bytes > MAX_MODEL_METADATA_BYTES { + return Err(invalid_request("model bundle metadata bound exceeded")); } - let plan = CompoundLoadPlan { + let bundle = ModelLoadBundle { version: PROTOCOL_VERSION, + format: MeshFormat::CompoundLdraw, primary: path.to_string(), - descriptors, + resources: bundled, }; - plan.validate() - .map_err(|error| invalid_request(format!("invalid compound plan: {error:?}")))?; - plan.encoded_size() - .map_err(|error| invalid_request(format!("compound plan encoding failed: {error:?}")))?; - Ok(axum::Json(plan)) + let encoded = bundle + .encode() + .map_err(|error| invalid_request(format!("model bundle encoding failed: {error:?}")))?; + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/vnd.polymodel.load-bundle", + ) + .header(header::CONTENT_LENGTH, encoded.len()) + .body(Body::from(encoded)) + .map_err(|error| internal(format!("model bundle response build failed: {error}"))) } pub(super) async fn fetch_resource( @@ -270,7 +298,7 @@ pub(super) async fn fetch_resource( .map_err(|error| internal(format!("LDraw response build failed: {error}"))) } -fn binding_for(project: &AtUri, resource: &ResolvedResource) -> AppResult { +pub(super) fn binding_for(project: &AtUri, resource: &ResolvedResource) -> AppResult { let publisher = resource.source.resource_uri.authority().convert(); let publisher = match publisher { jacquard_common::types::string::AtIdentifier::Did(did) => did, @@ -296,7 +324,8 @@ fn binding_for(project: &AtUri, resource: &ResolvedResource) -> AppResult AppResult { +#[cfg(test)] +pub(super) fn encode_binding(binding: &RouteBinding) -> AppResult { let payload = RoutePayload { v: binding.version, project: &binding.project, @@ -499,7 +528,7 @@ async fn fetch_verified_bytes( .to_owned(); let expected_length = u64::try_from(resource.byte_length) .map_err(|_| invalid_request("negative LDraw resource length"))?; - if expected_length != max_bytes || expected_length > MAX_COMPOUND_TOTAL_BYTES { + if expected_length != max_bytes || expected_length > MAX_MODEL_RESOURCE_BYTES { return Err(invalid_request("LDraw byte admission failed")); } let capacity = usize::try_from(expected_length) diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 623fa36..800c422 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -48,7 +48,8 @@ use polymodel_api::space_polymodel::{ }, library::{ delete_thing::DeleteThingRequest, get_author_things::GetAuthorThingsRequest, - get_feed::GetFeedRequest, get_model::GetModelRequest, get_part_file::GetPartFileRequest, + get_feed::GetFeedRequest, get_model::GetModelRequest, + get_model_load_bundle::GetModelLoadBundleRequest, get_part_file::GetPartFileRequest, get_thing::GetThingRequest, publish_thing::PublishThingRequest, search_things::SearchThingsRequest, stage_file::StageFileRequest, update_thing::UpdateThingRequest, @@ -124,9 +125,12 @@ pub fn router() -> Router { axum::routing::get(actor::get_session), ) .merge(GetPartFileRequest::into_router(downloads::get_part_file)) + .merge(GetModelLoadBundleRequest::into_router( + ldraw::get_model_load_bundle, + )) .route( - "/app/ldraw/compound-resources", - axum::routing::post(ldraw::compound_resources), + "/app/model-load-bundle", + axum::routing::post(ldraw::app_model_load_bundle), ) .route( "/app/ldraw/resources/{binding}", diff --git a/src/appview/read_tests.rs b/src/appview/read_tests.rs index 9e758c1..19a693e 100644 --- a/src/appview/read_tests.rs +++ b/src/appview/read_tests.rs @@ -603,37 +603,35 @@ async fn ordinary_projection_feeds_compound_resolver_and_route() { .clone() .oneshot( Request::builder() - .method(Method::POST) - .uri("/app/ldraw/compound-resources") - .header("content-type", "application/json") - .body(Body::from( - json!({ "project": project, "path": "models/main.ldr" }).to_string(), + .method(Method::GET) + .uri(format!( + "/xrpc/space.polymodel.library.getModelLoadBundle?uri={}", + urlencoding::encode(&part) )) + .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); - let plan: serde_json::Value = serde_json::from_slice( - &axum::body::to_bytes(response.into_body(), 1 << 20) - .await - .unwrap(), + let bundle_bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .unwrap(); + let bundle = polymodel_renderer_protocol::ModelLoadBundle::decode(&bundle_bytes).unwrap(); + assert_eq!(bundle.primary, "models/main.ldr"); + assert_eq!(bundle.resources.len(), 1); + let bundled = &bundle.resources[0]; + assert_eq!(bundled.key, "models/main.ldr"); + assert_eq!(bundled.target_cids[0], resource.target.blob_cids[0]); + assert_eq!(bundled.bytes, bytes); + + let route = crate::appview::ldraw::encode_binding( + &crate::appview::ldraw::binding_for(&project_uri, &resource).unwrap(), ) .unwrap(); - assert_eq!(plan["primary"], "models/main.ldr"); - assert_eq!(plan["descriptors"].as_array().unwrap().len(), 1); - let descriptor = &plan["descriptors"][0]; - assert_eq!(descriptor["key"], "models/main.ldr"); - assert_eq!(descriptor["target_cids"][0], resource.target.blob_cids[0]); - let fetched = app .clone() - .oneshot( - Request::builder() - .uri(descriptor["route"].as_str().unwrap()) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(Request::builder().uri(&route).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(fetched.status(), StatusCode::OK); @@ -648,7 +646,6 @@ async fn ordinary_projection_feeds_compound_resolver_and_route() { bytes.as_slice() ); - let route = descriptor["route"].as_str().unwrap().to_owned(); let route_variant = |mutate: &dyn Fn(&mut serde_json::Value)| { let encoded = route.strip_prefix("/app/ldraw/resources/").unwrap(); let (payload, _) = encoded.rsplit_once('.').unwrap(); @@ -837,16 +834,14 @@ async fn anonymous_compound_route_traverses_mpd_cycles_and_manifest_only_compani let request = || { Request::builder() .method(Method::POST) - .uri("/app/ldraw/compound-resources") + .uri("/app/model-load-bundle") .header("content-type", "application/json") - .body(Body::from( - json!({"project": project, "path": "models/root.ldr"}).to_string(), - )) + .body(Body::from(json!({"uri": root_uri}).to_string())) .unwrap() }; let first = app.clone().oneshot(request()).await.unwrap(); assert_eq!(first.status(), StatusCode::OK); - let first: serde_json::Value = serde_json::from_slice( + let first = polymodel_renderer_protocol::ModelLoadBundle::decode( &axum::body::to_bytes(first.into_body(), 1 << 20) .await .unwrap(), @@ -854,18 +849,17 @@ async fn anonymous_compound_route_traverses_mpd_cycles_and_manifest_only_compani .unwrap(); let second = app.oneshot(request()).await.unwrap(); assert_eq!(second.status(), StatusCode::OK); - let second: serde_json::Value = serde_json::from_slice( + let second = polymodel_renderer_protocol::ModelLoadBundle::decode( &axum::body::to_bytes(second.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); assert_eq!(first, second); - let keys = first["descriptors"] - .as_array() - .unwrap() + let keys = first + .resources .iter() - .map(|descriptor| descriptor["key"].as_str().unwrap()) + .map(|resource| resource.key.as_str()) .collect::>(); assert_eq!( keys, diff --git a/src/client.rs b/src/client.rs index 1525065..37e8a20 100644 --- a/src/client.rs +++ b/src/client.rs @@ -335,6 +335,27 @@ impl PolymodelClient { Self::decode(&response) } + /// Send an app request and return the successful response body unchanged. + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + pub async fn app_bytes( + &self, + method: http::Method, + path: &str, + body: Option<&B>, + ) -> Result, RawError> + where + B: serde::Serialize, + { + let (content_type, bytes) = match body { + Some(value) => ( + Some("application/json"), + serde_json::to_vec(value).map_err(|e| RawError::Build(e.to_string()))?, + ), + None => (None, Vec::new()), + }; + self.send_raw(method, path, content_type, &[], bytes).await + } + /// POST raw bytes (a file/image upload) and decode the JSON response. pub async fn app_post_bytes( &self, diff --git a/src/publish.rs b/src/publish.rs index a0230e3..13ea841 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -2462,10 +2462,12 @@ async fn generate_preview( #[cfg(all(target_family = "wasm", target_os = "unknown"))] mod renderer_preview { use polymodel_renderer_protocol::{ - Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, MeshFormat, - ProtocolHello, RendererCommand, RendererEvent, deserialize_event_envelope, - serialize_command_envelope, + Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadIdentity, + LoadOutput, LoadSource, MeshFormat, ModelLoadBundle, ModelResource, ProtocolHello, + RendererCommand, RendererEvent, RendererNamespace, deserialize_event_envelope, + required_capabilities, serialize_command_envelope, }; + use sha2::{Digest, Sha256}; use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::JsCast; @@ -2487,17 +2489,56 @@ mod renderer_preview { return Err(error); } }; - let result = - request_preview_png(MeshFormat::Gltf, glb_url.clone(), width, height).await; + let result = request_preview_png( + LoadSource::Url { + format: MeshFormat::Gltf, + primary_url: glb_url.clone(), + expected_size: None, + expected_digest: None, + }, + width, + height, + ) + .await; let _ = web_sys::Url::revoke_object_url(&glb_url); let _ = web_sys::Url::revoke_object_url(&step_url); return result; } + if is_ldraw_filename(filename) { + let byte_length = bytes.len() as u64; + let bundle = ModelLoadBundle { + version: polymodel_renderer_protocol::PROTOCOL_VERSION, + format: MeshFormat::CompoundLdraw, + primary: filename.to_owned(), + resources: vec![ModelResource { + key: filename.to_owned(), + path: filename.to_owned(), + root: "models".to_owned(), + byte_length, + sha256: Sha256::digest(&bytes).to_vec(), + mime_type: "text/plain".to_owned(), + target_cids: Vec::new(), + bytes, + }], + }; + return request_preview_png(LoadSource::Bundle(bundle), width, height).await; + } + let format = MeshFormat::from_extension(filename) .ok_or_else(|| "unsupported preview format".to_string())?; let blob_url = blob_url_for_bytes(bytes, mime_from_format(format))?; - let result = request_preview_png(format, blob_url.clone(), width, height).await; + let result = request_preview_png( + LoadSource::Url { + format, + primary_url: blob_url.clone(), + expected_size: None, + expected_digest: None, + }, + width, + height, + ) + .await; let _ = web_sys::Url::revoke_object_url(&blob_url); result } @@ -2531,6 +2572,13 @@ mod renderer_preview { .unwrap_or(false) } + fn is_ldraw_filename(filename: &str) -> bool { + filename + .rsplit_once('.') + .map(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "ldr" | "mpd")) + .unwrap_or(false) + } + async fn convert_step_to_glb_url(step_url: String) -> Result { let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); @@ -2633,8 +2681,7 @@ mod renderer_preview { } async fn request_preview_png( - format: MeshFormat, - primary_url: String, + source: LoadSource, width: u32, height: u32, ) -> Result, String> { @@ -2643,18 +2690,21 @@ mod renderer_preview { let worker = web_sys::Worker::new_with_options("/renderer_worker_loader.js", &opts) .map_err(|e| format!("preview worker: {e:?}"))?; let request_id = js_sys::Math::random().to_bits() as u32; - let command = RendererCommand::RenderPreviewImage { - request_id, - format, - primary_url, - width, - height, + let command = RendererCommand::StartLoad { + namespace: RendererNamespace::Preview, + identity: LoadIdentity::new(1, request_id.max(1)), + source, + output: LoadOutput::Preview { + request_id, + width, + height, + }, }; let local_hello = ProtocolHello { min_version: polymodel_renderer_protocol::PROTOCOL_VERSION, max_version: polymodel_renderer_protocol::PROTOCOL_VERSION, required: vec![Capability::CurrentFormats, Capability::Cancellation], - optional: vec![Capability::Preview], + optional: vec![Capability::Preview, Capability::CompoundLdraw], }; let handshake = Rc::new(RefCell::new(HandshakeState::Created)); let worker_for_message = worker.clone(); @@ -2716,10 +2766,15 @@ mod renderer_preview { Ok(Some(event)) => event, Ok(None) => { if let Some(protocol) = negotiated { - if !protocol.capabilities.contains(&Capability::Preview) { + if let Some(capability) = required_capabilities(&command) + .into_iter() + .find(|capability| !protocol.capabilities.contains(capability)) + { let _ = reject_message.call1( &JsValue::NULL, - &JsValue::from_str("preview capability was not negotiated"), + &JsValue::from_str(&format!( + "preview capability was not negotiated: {capability:?}" + )), ); return; } diff --git a/src/thing_detail.rs b/src/thing_detail.rs index 39c0062..639f7bb 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -19,8 +19,6 @@ use crate::client::PolymodelClient; use crate::session::SessionIdentity; use crate::viewer::{AssetViewer, ViewerFallbackImage, ViewerMeshSource, ViewerSourceFormat}; -const COMPOUND_PRIMARY_PATH: &str = "models/main.ldr"; - #[derive(Clone, Debug, PartialEq, Eq)] enum DetailState { Loading, @@ -84,26 +82,16 @@ fn part_download_href(uri: &AtUri) -> Option { )) } -fn viewer_source_for_part(part: &PartDetail, compound_project: &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)?; - let compound_path = if matches!( - format, - ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw) - ) { - COMPOUND_PRIMARY_PATH.to_string() - } else { - String::new() - }; Some(ViewerMeshSource { key: part.view.uri.as_str().to_string(), name, url, - compound_project: compound_project.as_str().to_string(), - compound_path, format, expected_size: Some(part.view.file.size), expected_digest: part.view.file.digest.as_deref().map(|b| b.to_vec()), @@ -500,7 +488,7 @@ fn DetailPopulated( let viewer_source = selected_model .as_ref() .and_then(|selected| selected.parts.first()) - .and_then(|part| viewer_source_for_part(part, &thing.uri)); + .and_then(viewer_source_for_part); let instructions = thing_record.instructions; rsx! { diff --git a/src/viewer.rs b/src/viewer.rs index 96c1b2f..0dc4116 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -16,12 +16,10 @@ use crate::client::PolymodelClient; use dioxus::prelude::*; #[cfg(target_arch = "wasm32")] -use polymodel_renderer_protocol::LoadIdentity; -#[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ - Capability, CommandEnvelope, CommandPayload, CompoundLoadPlan, EventPayload, HandshakeState, - LoadEvent, LoadReducer, ProtocolHello, RendererNamespace, deserialize_event_envelope, - serialize_command_envelope, + Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadEvent, + LoadIdentity, LoadOutput, LoadReducer, LoadSource, ModelLoadBundle, ProtocolHello, + RendererNamespace, deserialize_event_envelope, serialize_command_envelope, }; use polymodel_renderer_protocol::{ KeyModifiers, LoadErrorCode, MeshFormat, MeshStats, RendererCommand, RendererEvent, @@ -62,10 +60,6 @@ pub struct ViewerMeshSource { pub key: String, pub name: String, pub url: String, - /// Thing URI used as the anonymous compound-plan project identity. - pub compound_project: String, - /// Canonical LDraw path selected by the ordinary part mapping. - pub compound_path: String, pub format: ViewerSourceFormat, pub expected_size: Option, pub expected_digest: Option>, @@ -75,15 +69,13 @@ pub struct ViewerMeshSource { #[derive(Clone, Debug, PartialEq, Eq)] struct CompoundLogicalIntent { key: String, - project: String, - path: String, } #[cfg(target_arch = "wasm32")] #[derive(Clone, Debug, PartialEq, Eq)] struct PendingCompoundLoad { logical: CompoundLogicalIntent, - plan: CompoundLoadPlan, + bundle: ModelLoadBundle, acquisition_epoch: u32, } @@ -505,11 +497,7 @@ pub fn AssetViewer( clear_step_render_source(render_source, active_step_object_url); render_source.set(None); #[cfg(target_arch = "wasm32")] - compound_intent.set(Some(CompoundLogicalIntent { - key: source.key, - project: source.compound_project, - path: source.compound_path, - })); + compound_intent.set(Some(CompoundLogicalIntent { key: source.key })); compound_load.set(None); } ViewerSourceFormat::Renderer(format) => { @@ -579,9 +567,8 @@ pub fn AssetViewer( #[cfg(target_arch = "wasm32")] #[derive(serde::Serialize)] -struct CompoundPlanRequest<'a> { - project: &'a str, - path: &'a str, +struct ModelBundleRequest<'a> { + uri: &'a str, } #[cfg(target_arch = "wasm32")] @@ -595,40 +582,30 @@ fn start_compound_plan_load( mut status: Signal, ) { wasm_bindgen_futures::spawn_local(async move { + let path = format!( + "/xrpc/space.polymodel.library.getModelLoadBundle?uri={}", + urlencoding::encode(&logical.key) + ); let result = client - .app_json::<_, CompoundLoadPlan>( - http::Method::POST, - "/app/ldraw/compound-resources", - Some(&CompoundPlanRequest { - project: &logical.project, - path: &logical.path, - }), - ) - .await; + .app_bytes::>(http::Method::GET, &path, None) + .await + .and_then(|bytes| { + ModelLoadBundle::decode(&bytes) + .map_err(|error| crate::client::RawError::Decode(format!("{error:?}"))) + }); let current = *restart_epoch.read() == expected_restart_epoch && expected_intent.read().as_ref() == Some(&logical); if !current { return; } match result { - Ok(plan) => { - if let Err(error) = plan - .validate() - .and_then(|_| plan.encoded_size().map(|_| ())) - { - status.set(ViewerStatus::MeshError(format!( - "compound plan rejected: {error:?}" - ))); - return; - } - pending.set(Some(PendingCompoundLoad { - logical, - plan, - acquisition_epoch: expected_restart_epoch, - })); - } + Ok(bundle) => pending.set(Some(PendingCompoundLoad { + logical, + bundle, + acquisition_epoch: expected_restart_epoch, + })), Err(error) => status.set(ViewerStatus::MeshError(format!( - "compound plan acquisition failed: {error}" + "model bundle acquisition failed: {error}" ))), } }); @@ -1917,10 +1894,11 @@ impl WorkerBridgeState { pending.logical.key.clone(), identity.scene_generation as u64, )); - self.send_command(&RendererCommand::StartCompoundLoad { + self.send_command(&RendererCommand::StartLoad { namespace, identity, - plan: pending.plan, + source: LoadSource::Bundle(pending.bundle), + output: LoadOutput::Interactive, }); } @@ -1945,10 +1923,13 @@ impl WorkerBridgeState { self.send_command(&RendererCommand::StartLoad { namespace, identity, - format: source.format, - primary_url: source.primary_url, - expected_size: source.expected_size, - expected_digest: source.expected_digest, + source: LoadSource::Url { + format: source.format, + primary_url: source.primary_url, + expected_size: source.expected_size, + expected_digest: source.expected_digest, + }, + output: LoadOutput::Interactive, }); } @@ -2367,8 +2348,8 @@ fn mark_canvas_mounted( mod tests { use super::*; use polymodel_renderer_protocol::{ - KeyModifiers, LoadEvent, LoadIdentity, LoadReducer, MeshStats, RendererCommand, - RendererEvent, RendererNamespace, + KeyModifiers, LoadEvent, LoadIdentity, LoadOutput, LoadReducer, LoadSource, MeshStats, + RendererCommand, RendererEvent, RendererNamespace, }; #[test] @@ -2524,10 +2505,13 @@ mod tests { let start = RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), - format: MeshFormat::Stl, - primary_url: "/models/cube.stl".into(), - expected_size: None, - expected_digest: None, + source: LoadSource::Url { + format: MeshFormat::Stl, + primary_url: "/models/cube.stl".into(), + expected_size: None, + expected_digest: None, + }, + output: LoadOutput::Interactive, }; let resize = RendererCommand::Resize { css_w: 100.0, -- knotmirror 0.0.1