From 189043dbb038fa3dff5ec6b20aa9ddb44afa5be0 Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 10 Aug 2026 09:42:33 -0400 Subject: [PATCH] PM-84: ResourceClosure authority, shared geometry/UV, semantic event sink --- crates/polymodel-ldraw-core/src/model.rs | 45 +++++++++ crates/polymodel-ldraw-core/src/mpd.rs | 75 +++++++++++++- crates/polymodel-ldraw-core/src/traversal.rs | 48 +++++++++ .../src/bin/ldraw_preview.rs | 2 +- crates/polymodel-renderer-worker/src/lib.rs | 97 +++++++++++++++---- crates/polymodel-renderer-worker/src/tests.rs | 10 +- src/appview/ldraw.rs | 68 +++++++++++++ 7 files changed, 320 insertions(+), 25 deletions(-) diff --git a/crates/polymodel-ldraw-core/src/model.rs b/crates/polymodel-ldraw-core/src/model.rs index eaa7e6f..d397c8c 100644 --- a/crates/polymodel-ldraw-core/src/model.rs +++ b/crates/polymodel-ldraw-core/src/model.rs @@ -863,6 +863,51 @@ impl LdMeshSemantic { } } +/// Snapshot of semantic state at an emitted traversal event. +#[derive(Clone, Debug)] +pub struct TraversalContext { + pub transform: Transform, + pub colour: ColourCode, + pub bfc: BfcFrame, + pub texmap: Option, + pub source: CacheKey, + pub source_path: NormalizedPath, +} + +/// Typed semantic notifications emitted by the additive traversal event path. +#[derive(Clone, Debug)] +pub enum SemanticEvent { + ModelDefined { + model: ModelId, + path: NormalizedPath, + key: CacheKey, + }, + GeometryEmitted { + model: ModelId, + geometry: GeometryRecord, + }, + OccurrenceInstantiated { + model: ModelId, + target: ModelId, + transform: Transform, + colour: ColourCode, + }, + TexmapApplied { + association: TexmapAssociation, + }, + StepBoundary, + ColourDefined { + colour: ColourCode, + }, +} + +/// Consumer interface for semantic traversal events. +pub trait SemanticSink { + type Error; + + fn emit(&mut self, event: SemanticEvent, context: TraversalContext) -> Result<(), Self::Error>; +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ModelSummary { pub model: ModelId, diff --git a/crates/polymodel-ldraw-core/src/mpd.rs b/crates/polymodel-ldraw-core/src/mpd.rs index a92d114..bd7787b 100644 --- a/crates/polymodel-ldraw-core/src/mpd.rs +++ b/crates/polymodel-ldraw-core/src/mpd.rs @@ -1,3 +1,4 @@ +use crate::cache::Sha256Hash; use crate::cache::{CacheKey, NormalizedPath, RootId}; use crate::scanner::{LineType, ScannedLine, TokenKind}; use crate::types::{ @@ -269,20 +270,91 @@ pub struct ResourceDependency { pub fallback_available: bool, } +/// A fetched resource admitted by a [`ResourceClosure`]. +/// +/// The bytes and digest live on the closure itself so renderers do not need to +/// infer authority from a resolver-specific materialization list. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct ResolvedResource { + pub path: NormalizedPath, + pub root: RootId, + pub sha256: Vec, + pub byte_length: u64, + pub bytes: Vec, + pub mime_type: &'static str, + pub required: bool, + pub fallback_available: bool, +} + /// Typed, resolver-independent description of every resource needed by an /// LDraw render. Native preview and server bundling resolve these same entries /// through their respective filesystem/database adapters. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct ResourceClosure { pub include_references: Vec, pub dependencies: Vec, + pub resolved: Vec, /// Official libraries may provide standard colour definitions. The flag is /// intentionally optional at resolution time because uploaded sources can /// render with their inline colours and built-in fallback palette. pub needs_ldconfig: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ResourceClosureError { + #[error("resolved resource byte length mismatch")] + ByteLengthMismatch, + #[error("resolved resource SHA-256 mismatch")] + Sha256Mismatch, + #[error("required resource is missing from the closure")] + MissingRequired, +} + +impl ResolvedResource { + pub fn validate(&self) -> Result<(), ResourceClosureError> { + if self.byte_length != self.bytes.len() as u64 { + return Err(ResourceClosureError::ByteLengthMismatch); + } + if self.sha256.as_slice() != Sha256Hash::digest(&self.bytes).as_bytes() { + return Err(ResourceClosureError::Sha256Mismatch); + } + Ok(()) + } +} + impl ResourceClosure { + pub fn validate(&self) -> Result<(), ResourceClosureError> { + for resource in &self.resolved { + resource.validate()?; + } + for dependency in self + .dependencies + .iter() + .filter(|dependency| dependency.required) + { + let present = self.resolved.iter().any(|resource| { + resource.root == dependency.root && resource.path == dependency.path + }); + if !present { + return Err(ResourceClosureError::MissingRequired); + } + } + Ok(()) + } + + pub fn resolved_texture(&self, path: &NormalizedPath) -> Option<&ResolvedResource> { + let authored = path.as_str(); + let prefixed = format!("textures/{authored}"); + let stripped = authored.strip_prefix("textures/").unwrap_or(authored); + [authored, prefixed.as_str(), stripped] + .into_iter() + .find_map(|candidate| { + self.resolved.iter().find(|resource| { + resource.path.as_str() == candidate && resource.mime_type.starts_with("image/") + }) + }) + } + pub fn from_inspection( inspection: &CompoundSourceInspection, current_path: &NormalizedPath, @@ -330,6 +402,7 @@ impl ResourceClosure { Self { include_references: inspection.references.clone(), dependencies, + resolved: Vec::new(), needs_ldconfig: true, } } diff --git a/crates/polymodel-ldraw-core/src/traversal.rs b/crates/polymodel-ldraw-core/src/traversal.rs index 0ee1797..7d64b70 100644 --- a/crates/polymodel-ldraw-core/src/traversal.rs +++ b/crates/polymodel-ldraw-core/src/traversal.rs @@ -123,6 +123,54 @@ fn target_index( Ok(rooted.first().copied()) } +struct ParseSemanticSink; + +impl crate::SemanticSink for ParseSemanticSink { + type Error = ParseError; + + fn emit( + &mut self, + _event: crate::SemanticEvent, + _context: crate::TraversalContext, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +pub(crate) fn emit_model_events>( + model: &ModelData<'_>, + transform: Transform, + colour: crate::ColourCode, + sink: &mut S, +) -> Result<(), ParseError> { + let context = || crate::TraversalContext { + transform, + colour, + bfc: model.bfc, + texmap: None, + source: model.key.clone(), + source_path: model.path.clone(), + }; + sink.emit( + crate::SemanticEvent::ModelDefined { + model: crate::ModelId(model.file_id), + path: model.path.clone(), + key: model.key.clone(), + }, + context(), + )?; + for geometry in &model.geometry { + sink.emit( + crate::SemanticEvent::GeometryEmitted { + model: crate::ModelId(model.file_id), + geometry: geometry.clone(), + }, + context(), + )?; + } + Ok(()) +} + pub(crate) fn traverse( models: &[ModelData<'_>], options: &ParseOptions<'_>, diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs index a77ec76..4f7039f 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs @@ -826,7 +826,7 @@ fn run_visible_preview( frame.viewport.height, &pixels.into_iter().flatten().collect::>(), ) - .map_err(RendererError::External)?; + .map_err(|error| RendererError::External(error.to_string()))?; fs::write(&path, png).map_err(|error| { RendererError::Io(format!("write persistent readback: {error}")) })?; diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index e620c96..ade0c46 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -509,6 +509,7 @@ fn adapt_ldraw_assembly( } let mut geometries = Vec::with_capacity(models.len()); let mut provenance = Vec::with_capacity(models.len()); + let mut uv_cache = HashMap::<(GeometryId, u64, u64), Rc>>::new(); let mut texture_ids = HashMap::<(RootId, String, String), TextureId>::new(); let mut textures = Vec::::new(); let mut texture_for = |root: RootId, @@ -784,8 +785,35 @@ fn adapt_ldraw_assembly( .get(&GeometryId(node.0)) .copied() .ok_or_else(|| RendererError::MissingGeometry(GeometryId(node.0)))?; - let occurrence_uvs = - occurrence_texmap_uvs(texmap, transform, &geometries[source_index].points)?; + use std::hash::{Hash, Hasher}; + let mut transform_hasher = std::collections::hash_map::DefaultHasher::new(); + for value in transform.into_iter().flatten() { + value.to_bits().hash(&mut transform_hasher); + } + let transform_hash = transform_hasher.finish(); + let mut texmap_hasher = std::collections::hash_map::DefaultHasher::new(); + match texmap.mode { + SceneTexmapProjectionMode::Planar => 0_u8.hash(&mut texmap_hasher), + SceneTexmapProjectionMode::Cylindrical => 1_u8.hash(&mut texmap_hasher), + SceneTexmapProjectionMode::Spherical => 2_u8.hash(&mut texmap_hasher), + } + texmap.parameters.hash(&mut texmap_hasher); + texmap.pngfile.hash(&mut texmap_hasher); + texmap.glossmap.hash(&mut texmap_hasher); + texmap.fallback.hash(&mut texmap_hasher); + let texmap_hash = texmap_hasher.finish(); + let cache_key = (GeometryId(node.0), transform_hash, texmap_hash); + let occurrence_uvs = if let Some(uvs) = uv_cache.get(&cache_key) { + uvs.clone() + } else { + let uvs = Rc::new(occurrence_texmap_uvs( + texmap, + transform, + &geometries[source_index].points, + )?); + uv_cache.insert(cache_key, uvs.clone()); + uvs + }; (*node, Some(occurrence_uvs)) } else { (*node, None) @@ -802,7 +830,7 @@ fn adapt_ldraw_assembly( logical_part_occurrence_id: logical_part.as_ref().map(|(id, _)| id.clone()), logical_part_path: logical_part.as_ref().map(|(_, path)| path.clone()), node: render_node, - uvs: occurrence_uvs, + uvs: occurrence_uvs.as_ref().map(|uvs| uvs.as_ref().clone()), definition_id: metadata.definition_id.clone(), canonical_path: metadata.canonical_path.clone(), source_class: metadata.source_class.clone(), @@ -1825,14 +1853,16 @@ pub fn semantic_counts(semantic: &LdMeshSemantic) -> (u64, u64) { #[cfg(not(target_arch = "wasm32"))] fn official_texture_resources( closure: &polymodel_ldraw_core::ResourceClosure, - materializations: &[Materialization], ) -> Result, RendererError> { + closure.validate().map_err(|error| { + RendererError::External(format!("official resource closure rejected: {error}")) + })?; let mut resources = Vec::new(); for dependency in closure.texture_dependencies() { - let Some(materialization) = materializations.iter().find(|materialization| { - materialization.root == dependency.root - && materialization.path == dependency.path - && materialization.available + let Some(resolved) = closure.resolved.iter().find(|resource| { + resource.root == dependency.root + && resource.path == dependency.path + && resource.mime_type.starts_with("image/") }) else { continue; }; @@ -1840,11 +1870,11 @@ fn official_texture_resources( key: dependency.path.as_str().to_owned(), path: dependency.path.as_str().to_owned(), root: texture_resource_root(dependency.root).to_owned(), - byte_length: materialization.bytes.len() as u64, - sha256: materialization.content_hash.as_bytes().to_vec(), - mime_type: "image/png".to_owned(), + byte_length: resolved.byte_length, + sha256: resolved.sha256.clone(), + mime_type: resolved.mime_type.to_owned(), target_cids: Vec::new(), - bytes: materialization.bytes.clone(), + bytes: resolved.bytes.clone(), }; resource.validate().map_err(|error| { RendererError::External(format!("official TEXMAP resource rejected: {error:?}")) @@ -1888,8 +1918,7 @@ pub fn prepare_preview_scene( closure.materializations.len(), closure.target_selections.len(), ); - let resources = - official_texture_resources(&closure.resource_closure, &closure.materializations)?; + let resources = official_texture_resources(&closure.resource_closure)?; let mut materializations = closure.materializations; let target_selections = closure.target_selections; materializations.retain(|materialization| { @@ -2009,6 +2038,7 @@ pub fn official_materializations( let mut resource_closure = polymodel_ldraw_core::ResourceClosure { include_references: Vec::new(), dependencies: Vec::new(), + resolved: Vec::new(), needs_ldconfig: false, }; let mut total_bytes = root_bytes.len(); @@ -2241,7 +2271,11 @@ pub fn official_materializations( // Resolve auxiliary resources through the same typed closure consumed by // server bundling. Keep texture bytes in the official closure so native // preview can pass the exact admitted assets to scene adaptation. - for dependency in resource_closure.texture_dependencies() { + let texture_dependencies = resource_closure + .texture_dependencies() + .cloned() + .collect::>(); + for dependency in texture_dependencies { let candidate = library_root.join(dependency.path.as_str()); if !candidate.is_file() { if dependency.required { @@ -2263,6 +2297,21 @@ pub fn official_materializations( candidate.display() )) })?; + let sha256 = polymodel_ldraw_core::Sha256Hash::digest(&bytes) + .as_bytes() + .to_vec(); + resource_closure + .resolved + .push(polymodel_ldraw_core::ResolvedResource { + path: dependency.path.clone(), + root: dependency.root, + sha256, + byte_length: bytes.len() as u64, + bytes: bytes.clone(), + mime_type: "image/png", + required: dependency.required, + fallback_available: dependency.fallback_available, + }); materializations.push(Materialization::new( dependency.path.clone(), dependency.root, @@ -2299,6 +2348,9 @@ pub fn official_materializations( } } + resource_closure.validate().map_err(|error| { + RendererError::External(format!("official resource closure rejected: {error}")) + })?; Ok(OfficialClosure { materializations, target_selections, @@ -3418,8 +3470,8 @@ pub struct SceneBatchInstance { #[derive(Debug, Clone, PartialEq)] pub struct SceneInstanceBatch { pub node: NodeId, - /// UVs owned by a textured occurrence; base geometry remains shared by node. - pub uvs: Option>, + /// UVs shared by occurrences with identical geometry/transform/TEXMAP keys. + pub uvs: Option>>, pub material: MaterialId, pub bfc_cull: bool, pub bfc_ccw: bool, @@ -3627,7 +3679,7 @@ fn scene_instance_batches_for_mode( let logical_part_path = occurrence .logical_part_path .clone() - .unwrap_or_else(|| occurrence.canonical_path.clone()); + .unwrap_or_else(|| occurrence.canonical_path.to_string().into()); accumulator.pick_occurrences.push(PlannedOccurrence { occurrence_index, geometry_index, @@ -3636,7 +3688,7 @@ fn scene_instance_batches_for_mode( .material(scene, occurrence.material.unwrap_or(default_material))? .colour, canonical_occurrence_id: occurrence.canonical_occurrence_id.clone(), - canonical_path: occurrence.canonical_path.clone(), + canonical_path: occurrence.canonical_path.to_string().into(), logical_part_occurrence_id: logical_part_occurrence_id.clone(), logical_part_path, transform: occurrence.transform, @@ -3692,7 +3744,7 @@ fn scene_instance_batches_for_mode( let index = batches.len(); batches.push(SceneInstanceBatch { node: occurrence.node, - uvs: occurrence.uvs.clone(), + uvs: occurrence.uvs.as_ref().map(|uvs| Rc::new(uvs.clone())), material, bfc_cull: occurrence.bfc_cull, bfc_ccw: occurrence.bfc_ccw, @@ -5251,8 +5303,11 @@ fn build_scene_model_from_batches( uvs: batch .uvs .as_deref() + .map(|uvs| uvs.as_slice()) .or_else(|| (!topology.uvs.is_empty()).then_some(topology.uvs.as_slice())) - .map(|uvs| uvs.iter().map(|uv| three_d::vec2(uv[0], uv[1])).collect()), + .map(|uvs: &[[f32; 2]]| { + uvs.iter().map(|uv| three_d::vec2(uv[0], uv[1])).collect() + }), ..Default::default() }; cpu_mesh.compute_normals(); diff --git a/crates/polymodel-renderer-worker/src/tests.rs b/crates/polymodel-renderer-worker/src/tests.rs index 38ab1b0..6c357b5 100644 --- a/crates/polymodel-renderer-worker/src/tests.rs +++ b/crates/polymodel-renderer-worker/src/tests.rs @@ -723,8 +723,14 @@ fn textured_occurrences_share_geometry_and_split_uv_batches() { assert_eq!(scene.geometries.len(), 1); assert_eq!(scene.occurrences[0].node, scene.occurrences[1].node); assert_eq!(plan.batches.len(), 2); - assert_eq!(plan.batches[0].uvs, Some(uv_a)); - assert_eq!(plan.batches[1].uvs, Some(uv_b)); + assert_eq!( + plan.batches[0].uvs.as_ref().map(|uvs| uvs.as_slice()), + Some(uv_a.as_slice()) + ); + assert_eq!( + plan.batches[1].uvs.as_ref().map(|uvs| uvs.as_slice()), + Some(uv_b.as_slice()) + ); let picker = ScenePicker::new(&scene, plan).expect("picker with UV-split batches"); let hit = picker .pick(PickRay { diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index 835ce64..d3d38e9 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -437,6 +437,12 @@ async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult AppResult "projection", @@ -728,6 +741,25 @@ async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult "image/png", + "image/jpeg" => "image/jpeg", + "image/webp" => "image/webp", + _ => "application/octet-stream", + }, + required: dependency.required, + fallback_available: dependency.fallback_available, + }); children.push(( path, BundleNode::Official(official.clone()), @@ -735,12 +767,48 @@ async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult resolved.path.clone(), + BundleNode::Official(official) => official.path.clone(), + }; + let node_mime = match &node { + BundleNode::Db(resolved) if resolved.mime_type == "image/png" => "image/png", + BundleNode::Db(resolved) if resolved.mime_type == "image/jpeg" => "image/jpeg", + BundleNode::Db(resolved) if resolved.mime_type == "image/webp" => "image/webp", + BundleNode::Db(_) => "application/octet-stream", + BundleNode::Official(official) if official.mime_type == "image/png" => "image/png", + BundleNode::Official(official) if official.mime_type == "image/jpeg" => "image/jpeg", + BundleNode::Official(official) if official.mime_type == "image/webp" => "image/webp", + BundleNode::Official(_) => "application/octet-stream", + }; + let node_sha256 = match &node { + BundleNode::Db(resolved) => binding_for(&project, resolved)?.sha256, + BundleNode::Official(official) => official.sha256.clone(), + }; + resolved_closure + .resolved + .push(polymodel_ldraw_core::ResolvedResource { + path: polymodel_renderer_protocol::NormalizedPath::new(node_path.as_str()) + .map_err(|_| invalid_request("invalid resolved resource path"))?, + root: protocol_root(node_root), + sha256: node_sha256, + byte_length: u64::try_from(bytes.len()) + .map_err(|_| invalid_request("resolved resource byte length overflow"))?, + bytes: bytes.clone(), + mime_type: node_mime, + required: false, + fallback_available: true, + }); resources.push((node, bytes)); for (child_path, child_node, child_bytes, child_key) in children { queue.push_back((child_path, child_node, child_bytes, child_key)); } } + resolved_closure + .validate() + .map_err(|error| invalid_request(format!("invalid resolved resource closure: {error}")))?; encode_bundle_response( &project, &path, -- knotmirror 0.0.1