diff --git a/crates/polymodel-ldraw-core/src/adapter.rs b/crates/polymodel-ldraw-core/src/adapter.rs index 5426083..4f73062 100644 --- a/crates/polymodel-ldraw-core/src/adapter.rs +++ b/crates/polymodel-ldraw-core/src/adapter.rs @@ -10,8 +10,8 @@ use std::collections::HashMap; pub struct InProcessRustAdapter; #[derive(Default)] -struct CompatibilitySink { - geometry: HashMap>, +pub(crate) struct CompatibilitySink { + pub(crate) geometry: HashMap>, } impl crate::SemanticSink for CompatibilitySink { diff --git a/crates/polymodel-ldraw-core/src/lib.rs b/crates/polymodel-ldraw-core/src/lib.rs index c7cfd69..9663085 100644 --- a/crates/polymodel-ldraw-core/src/lib.rs +++ b/crates/polymodel-ldraw-core/src/lib.rs @@ -170,30 +170,32 @@ mod tests { true, None, ); - let result = LdrawParser - .parse_bytes(bytes, options(), &[materialization]) - .expect("semantic event fixture should parse"); - let geometry = result - .events - .iter() - .filter(|record| matches!(record.event, SemanticEvent::GeometryEmitted { .. })) - .count(); - let includes = result - .events - .iter() - .filter(|record| matches!(record.event, SemanticEvent::IncludeInstantiated { .. })) - .count(); - assert_eq!(geometry, 2); - assert_eq!(includes, 1); - let include = result - .events - .iter() - .find_map(|record| match &record.event { - SemanticEvent::IncludeInstantiated { transform, .. } => { - Some((*transform, record.context.source_span)) + #[derive(Default)] + struct RecordingSink { + geometry: usize, + includes: Vec<(Transform, Option)>, + } + impl SemanticSink for RecordingSink { + fn on_event(&mut self, event: &SemanticEvent<'_>, context: &TraversalContext) { + match event { + SemanticEvent::GeometryEmitted { .. } => self.geometry += 1, + SemanticEvent::IncludeInstantiated { transform, .. } => { + self.includes.push((*transform, context.source_span)) + } + _ => {} } - _ => None, - }) + } + } + let mut sink = RecordingSink::default(); + LdrawParser + .parse_bytes_with_sink(bytes, options(), &[materialization], &mut sink) + .expect("semantic event fixture should parse"); + assert_eq!(sink.geometry, 2); + assert_eq!(sink.includes.len(), 1); + let include = sink + .includes + .first() + .copied() .expect("include event should be present"); assert_eq!(include.0.translation, Point3::new(1.0, 2.0, 3.0)); assert!(include.1.is_some()); @@ -861,42 +863,12 @@ mod tests { } } - #[derive(Default)] - struct TestSink { - geometry: Vec, - } - - impl SemanticSink for TestSink { - fn on_event(&mut self, event: &SemanticEvent<'_>, _context: &TraversalContext) { - if let SemanticEvent::GeometryEmitted { - primitive_id, - source, - span, - line_type, - colour, - vertices, - bfc, - texmap, - inverted, - } = event - { - self.geometry.push(GeometryRecord { - primitive_id: *primitive_id, - source: source.clone(), - span: *span, - line_type: *line_type, - colour: *colour, - vertices: vertices.to_vec(), - bfc: *bfc, - inverted: *inverted, - texmap: texmap.cloned(), - }); - } - } - } - - fn event_geometry(_result: &ParseResult<'_>, _model: &ModelSummary) -> Vec { - panic!("event_geometry requires a sink-backed test parse") + fn event_geometry(result: &OwnedParseResult, model: &ModelSummary) -> Vec { + result + .geometry + .get(&model.cache_key) + .cloned() + .unwrap_or_default() } fn assert_scene_counts( @@ -915,7 +887,7 @@ mod tests { ); } - fn assert_sticker_geometry(result: &ParseResult<'_>) { + fn assert_sticker_geometry(result: &OwnedParseResult) { let sticker = result .models .iter() diff --git a/crates/polymodel-ldraw-core/src/parser.rs b/crates/polymodel-ldraw-core/src/parser.rs index d65c1d9..bae4e25 100644 --- a/crates/polymodel-ldraw-core/src/parser.rs +++ b/crates/polymodel-ldraw-core/src/parser.rs @@ -85,8 +85,10 @@ impl LdrawParser { options: ParseOptions<'a>, materializations: &[Materialization], ) -> Result { - let mut sink = crate::NoopSemanticSink; - self.parse_bytes_with_sink(bytes, options, materializations, &mut sink) + let mut sink = crate::CompatibilitySink::default(); + let mut result = self.parse_bytes_with_sink(bytes, options, materializations, &mut sink)?; + result.geometry = sink.geometry; + Ok(result) } pub fn parse_bytes_with_sink<'src, 'a>( diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs index 639dd14..55a8272 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs @@ -147,7 +147,13 @@ fn main() { }, trace, ) { - eprintln!("ldraw_preview: {error}"); + eprintln!( + "ldraw_preview: {}", + error + .external_message() + .map(str::to_owned) + .unwrap_or_else(|| error.to_string()) + ); std::process::exit(1); } } @@ -263,7 +269,11 @@ fn terminal_result( match snapshot.terminal { Some(NativeTerminal::Success) => Ok(()), Some(NativeTerminal::Failure(error)) | Some(NativeTerminal::Timeout(error)) => { - Err(RendererError::External(error)) + let detail = error + .strip_prefix("external runtime failure: ") + .unwrap_or(&error) + .to_owned(); + Err(RendererError::External(detail)) } None => Err(RendererError::External( "native preview exited without terminal state".to_owned(), diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index 3d5114d..5355cb6 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -190,6 +190,13 @@ impl RendererError { fn parse(message: &'static str) -> Self { Self::ParseAdaptation(message) } + + pub fn external_message(&self) -> Option<&str> { + match self { + Self::External(message) => Some(message), + _ => None, + } + } } fn finite_f32(value: f64) -> Result { @@ -317,14 +324,40 @@ struct PrimitiveBatchKey { texmap: Option, } -fn append_boxed(slot: &mut Box<[T]>, items: impl IntoIterator) { - let mut values = std::mem::take(slot).into_vec(); - values.extend(items); - *slot = values.into_boxed_slice(); +fn append_items(slot: &mut Vec, items: impl IntoIterator) { + slot.extend(items); +} + +struct SceneGeometryBuilder { + id: GeometryId, + provenance: ProvenanceId, + points: Vec<[f64; 3]>, + uvs: Vec<[f32; 2]>, + triangles: Vec<[u32; 3]>, + quads: Vec<[u32; 4]>, + lines: Vec<[u32; 2]>, + conditional_lines: Vec<[u32; 4]>, + primitives: Vec, +} + +impl SceneGeometryBuilder { + fn into_scene_geometry(self) -> SceneGeometry { + SceneGeometry { + id: self.id, + provenance: self.provenance, + points: self.points.into_boxed_slice(), + uvs: self.uvs.into_boxed_slice(), + triangles: self.triangles.into_boxed_slice(), + quads: self.quads.into_boxed_slice(), + lines: self.lines.into_boxed_slice(), + conditional_lines: self.conditional_lines.into_boxed_slice(), + primitives: self.primitives.into_boxed_slice(), + } + } } struct AdapterSink<'a> { - geometries: Vec, + geometries: Vec, provenance: Vec, nodes_by_model: HashMap>, metadata_by_model: HashMap, @@ -473,16 +506,16 @@ impl<'a> AdapterSink<'a> { } else { let id = GeometryId((self.geometries.len() + 1) as u64); let index = self.geometries.len(); - self.geometries.push(SceneGeometry { + self.geometries.push(SceneGeometryBuilder { id, provenance, - points: Box::default(), - uvs: Box::default(), - triangles: Box::default(), - quads: Box::default(), - lines: Box::default(), - conditional_lines: Box::default(), - primitives: Box::default(), + points: Vec::new(), + uvs: Vec::new(), + triangles: Vec::new(), + quads: Vec::new(), + lines: Vec::new(), + conditional_lines: Vec::new(), + primitives: Vec::new(), }); self.nodes_by_model .entry(model.clone()) @@ -499,7 +532,7 @@ impl<'a> AdapterSink<'a> { u32::try_from(vertices.len()).map_err(|_| RendererError::IndexOverflow { context: "primitive point count", })?; - append_boxed( + append_items( &mut batch.primitives, [ScenePrimitiveRange { point_start: base, @@ -516,7 +549,7 @@ impl<'a> AdapterSink<'a> { .flatten(), }], ); - append_boxed( + append_items( &mut batch.points, vertices .iter() @@ -558,19 +591,19 @@ impl<'a> AdapterSink<'a> { &batch.points[batch.points.len() - vertices.len()..], ) .map_err(|_| RendererError::parse("invalid TEXMAP UV projection"))?; - append_boxed(&mut batch.uvs, generated); + append_items(&mut batch.uvs, generated); } match line_type { LineType::Two if vertices.len() == 2 => { - append_boxed(&mut batch.lines, [[base, base + 1]]) + append_items(&mut batch.lines, [[base, base + 1]]) } LineType::Three if vertices.len() == 3 => { - append_boxed(&mut batch.triangles, [[base, base + 1, base + 2]]) + append_items(&mut batch.triangles, [[base, base + 1, base + 2]]) } LineType::Four if vertices.len() == 4 => { - append_boxed(&mut batch.quads, [[base, base + 1, base + 2, base + 3]]) + append_items(&mut batch.quads, [[base, base + 1, base + 2, base + 3]]) } - LineType::Five if vertices.len() == 4 => append_boxed( + LineType::Five if vertices.len() == 4 => append_items( &mut batch.conditional_lines, [[base, base + 1, base + 2, base + 3]], ), @@ -698,7 +731,11 @@ fn adapt_ldraw_assembly_with_sink( ); } } - let mut geometries = adapter_sink.geometries; + let mut geometries = adapter_sink + .geometries + .into_iter() + .map(SceneGeometryBuilder::into_scene_geometry) + .collect::>(); let mut provenance = adapter_sink.provenance; let mut uv_cache = HashMap::<(GeometryId, u64, u64), Arc<[[f32; 2]]>>::new(); let mut uv_cache_hits = 0_u64; @@ -2191,7 +2228,7 @@ pub fn official_materializations( root_bytes: &[u8], ) -> Result { use polymodel_ldraw_core::CacheKey; - use std::collections::{HashSet, VecDeque}; + use std::collections::{HashMap, HashSet, VecDeque}; const MAX_FILES: usize = 4096; const MAX_EDGES: usize = 16_384; @@ -2227,6 +2264,7 @@ pub fn official_materializations( depth: 0, }]); let mut seen = HashSet::::new(); + let mut materialization_cache = HashMap::<(RootId, NormalizedPath), (CacheKey, Vec)>::new(); let mut materializations = Vec::new(); let mut target_selections = Vec::new(); let mut resource_closure = polymodel_ldraw_core::ResourceClosure { @@ -2415,10 +2453,19 @@ pub fn official_materializations( .map_err(|error| { RendererError::External(format!("normalize official part path: {error}")) })?; - let child_bytes = std::fs::read(&canonical_path).map_err(|error| { - RendererError::Io(format!("read {}: {error}", canonical_path.display())) - })?; - let target = CacheKey::new(logical_path.clone(), RootId::OfficialLibrary, &child_bytes); + let cache_key = (RootId::OfficialLibrary, logical_path.clone()); + let (target, child_bytes) = + if let Some((target, bytes)) = materialization_cache.get(&cache_key) { + (target.clone(), bytes.clone()) + } else { + let child_bytes = std::fs::read(&canonical_path).map_err(|error| { + RendererError::Io(format!("read {}: {error}", canonical_path.display())) + })?; + let target = + CacheKey::new(logical_path.clone(), RootId::OfficialLibrary, &child_bytes); + materialization_cache.insert(cache_key, (target.clone(), child_bytes.clone())); + (target, child_bytes) + }; target_selections.push(polymodel_ldraw_core::TargetSelection { source, span: reference.span, @@ -3675,6 +3722,32 @@ pub struct SceneInstanceBatch { pub instances: Box<[SceneBatchInstance]>, } +struct SceneInstanceBatchBuilder { + node: NodeId, + uvs: Option>, + material: MaterialId, + bfc_cull: bool, + bfc_ccw: bool, + bfc_inverted: bool, + reflection: bool, + instances: Vec, +} + +impl SceneInstanceBatchBuilder { + fn into_batch(self) -> SceneInstanceBatch { + SceneInstanceBatch { + node: self.node, + uvs: self.uvs, + material: self.material, + bfc_cull: self.bfc_cull, + bfc_ccw: self.bfc_ccw, + bfc_inverted: self.bfc_inverted, + reflection: self.reflection, + instances: self.instances.into_boxed_slice(), + } + } +} + #[derive(Debug, Clone, PartialEq)] pub struct SceneRenderPlan { /// Planned batches retain all semantic occurrences for picking and diagnostics. @@ -3862,7 +3935,7 @@ fn scene_instance_batches_for_mode( .first() .map(|material| material.id) .unwrap_or(MaterialId(1)); - let mut batches = Vec::::new(); + let mut batches = Vec::::new(); let mut batch_indices = std::collections::HashMap::::new(); for (occurrence_index, occurrence) in scene.occurrences.iter().enumerate() { let geometry_index = index.geometry_index(occurrence.node)?; @@ -3937,10 +4010,10 @@ fn scene_instance_batches_for_mode( transform: occurrence.transform, }; if let Some(index) = batch_indices.get(&key).copied() { - append_boxed(&mut batches[index].instances, [instance]); + batches[index].instances.push(instance); } else { let index = batches.len(); - batches.push(SceneInstanceBatch { + batches.push(SceneInstanceBatchBuilder { node: occurrence.node, uvs: occurrence.uvs.clone(), material, @@ -3948,12 +4021,15 @@ fn scene_instance_batches_for_mode( bfc_ccw: occurrence.bfc_ccw, bfc_inverted: reverse_winding, reflection, - instances: Box::new([instance]), + instances: vec![instance], }); batch_indices.insert(key, index); } } - Ok(batches) + Ok(batches + .into_iter() + .map(SceneInstanceBatchBuilder::into_batch) + .collect()) } #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 9bbfa76..39bc7df 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -616,10 +616,10 @@ mod tests { path: "root.ldr".into(), root: "mpd".into(), byte_length: bytes.len() as u64, - sha256: vec![0; 32], + sha256: [0; 32], mime_type: "application/x-ldraw".into(), - target_cids: Vec::new(), - bytes: bytes.to_vec(), + target_cids: Box::default(), + bytes: bytes.to_vec().into(), }], source_identities: Vec::new(), target_selections: Vec::new(), @@ -703,20 +703,20 @@ mod tests { path: "models/omr-10030-1.mpd".into(), root: "mpd".into(), byte_length: 1, - sha256: vec![0; 32], + sha256: [0; 32], mime_type: "application/x-ldraw".into(), - target_cids: Vec::new(), - bytes: vec![0], + target_cids: Box::default(), + bytes: Box::new([0]), }, polymodel_renderer_protocol::ModelResource { key: "parts/2625.dat".into(), path: "parts/2625.dat".into(), root: "official-parts".into(), byte_length: 1, - sha256: vec![0; 32], + sha256: [0; 32], mime_type: "application/x-ldraw".into(), - target_cids: Vec::new(), - bytes: vec![0], + target_cids: Box::default(), + bytes: Box::new([0]), }, ]; let materializations = compound_materializations(&resources, "models/omr-10030-1.mpd") @@ -744,10 +744,10 @@ mod tests { path: "models/fixture.mpd".into(), root: "models".into(), byte_length: bytes.len() as u64, - sha256: vec![0; 32], + sha256: [0; 32], mime_type: "application/x-ldraw".into(), - target_cids: Vec::new(), - bytes: bytes.to_vec(), + target_cids: Box::default(), + bytes: bytes.to_vec().into(), }], source_identities: inspection .source_keys @@ -776,7 +776,11 @@ mod tests { fn descriptor_roots_reject_malformed_and_colliding_labels() { for root in ["", "manifest/", "official", "models/../parts", "unknown"] { let error = descriptor_root(root).expect_err("malformed root must be rejected"); - assert!(error.contains("unsupported compound descriptor root")); + assert!( + error + .to_string() + .contains("unsupported compound descriptor root") + ); } assert_ne!( descriptor_root("manifest").unwrap(), @@ -997,7 +1001,10 @@ mod tests { let Err(message) = build_scene(&context, &model, 8, 8) else { panic!("points are preserved but not rendered yet"); }; - assert_eq!(message, "model contains no renderable triangle geometry"); + assert_eq!( + message, + RendererError::ParseAdaptation("model contains no renderable triangle geometry") + ); } } diff --git a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs index 91d4493..851719b 100644 --- a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs @@ -59,9 +59,11 @@ fn isolated_plate() -> IsolatedPlate { .iter() .find(|model| model.cache_key == plate_key) .expect("3021 CacheKey must own a parsed model"); - let body = model + let body = result .geometry - .iter() + .get(&model.cache_key) + .into_iter() + .flatten() .filter(|geometry| geometry.line_type == polymodel_ldraw_core::LineType::Four) .collect::>(); assert_eq!(body.len(), 4, "3021 must retain its four source body quads"); @@ -1085,13 +1087,13 @@ fn identity_and_reflected_topologies_preserve_world_facing_normal() { let geometry = SceneGeometry { id: GeometryId(1), provenance: ProvenanceId(1), - points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], - uvs: Vec::new(), - triangles: vec![[0, 1, 2]], - quads: Vec::new(), - lines: Vec::new(), - conditional_lines: Vec::new(), - primitives: Vec::new(), + points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]].into(), + uvs: Vec::new().into(), + triangles: vec![[0, 1, 2]].into(), + quads: Vec::new().into(), + lines: Vec::new().into(), + conditional_lines: Vec::new().into(), + primitives: Vec::new().into(), }; let normal_z = |topology: &polymodel_renderer_worker::SceneMeshTopology| { let [a, b, c] = topology.indices[..3] else {