From 0cb85549822b3d54b1a21c155493cda7b03a6987 Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 9 Aug 2026 12:14:28 -0400 Subject: [PATCH] PM-82: fix UV seams, occurrence transforms, test matrix, corpus validation Epic: PM-86 Task: PM-82 --- crates/polymodel-ldraw-core/src/lib.rs | 66 ++++++++++- .../expected/pm82-data-textures.expected.json | 23 +++- .../pm82-texmap-boundaries.expected.json | 106 ++++++++++++++++- .../pm82-texmap-data-preview.expected.json | 23 +++- .../pm82-texmap-includes.expected.json | 24 ---- .../polymodel-renderer-protocol/src/scene.rs | 7 ++ crates/polymodel-renderer-worker/src/lib.rs | 90 ++++++++++++-- crates/polymodel-renderer-worker/src/tests.rs | 110 +++++++++++++++++- .../polymodel-renderer-worker/src/texture.rs | 25 +++- 9 files changed, 425 insertions(+), 49 deletions(-) diff --git a/crates/polymodel-ldraw-core/src/lib.rs b/crates/polymodel-ldraw-core/src/lib.rs index 8e7200e..793f6c1 100644 --- a/crates/polymodel-ldraw-core/src/lib.rs +++ b/crates/polymodel-ldraw-core/src/lib.rs @@ -706,11 +706,73 @@ mod tests { fixture.id ); assert!( - !projected.syntax.is_empty() - || projected.schema_version == polymodel_ldraw_testkit::SCHEMA_VERSION, + !projected.syntax.is_empty(), "{} accepted fixture should produce syntax records", fixture.id ); + let expected_policy = &fixture.expected.polymodel_policy_expected; + let actual_events = serde_json::Value::Array( + projected + .semantic + .texmap_events + .iter() + .map(|event| { + serde_json::json!({ + "kind": event.kind, + "mode": event.mode, + "parameters": event.parameters, + "reference": event.reference, + "state": event.state, + }) + }) + .collect(), + ); + let actual_geometry = serde_json::Value::Array( + projected + .semantic + .texmap_geometry + .iter() + .map(|geometry| { + serde_json::json!({ + "line_type": geometry.line_type, + "association": geometry.association, + }) + }) + .collect(), + ); + let actual_data = serde_json::to_value(&projected.semantic.data_payloads) + .expect("DATA payloads should serialize"); + if fixture.id.starts_with("pm82-") { + assert_eq!( + actual_events, + serde_json::Value::Array(expected_policy.texmap_events.clone()), + "{} TEXMAP events", + fixture.id + ); + assert_eq!( + actual_geometry, + serde_json::Value::Array(expected_policy.texmap_geometry.clone()), + "{} TEXMAP geometry", + fixture.id + ); + assert_eq!( + actual_data, + serde_json::Value::Array(expected_policy.data_payloads.clone()), + "{} DATA payloads", + fixture.id + ); + assert_eq!( + projected + .semantic + .limits + .iter() + .map(ToString::to_string) + .collect::>(), + expected_policy.limits, + "{} policy limits", + fixture.id + ); + } if fixture.expected.outcome == polymodel_ldraw_testkit::ExpectedOutcome::Cancelled { diff --git a/crates/polymodel-ldraw-testkit/corpus/expected/pm82-data-textures.expected.json b/crates/polymodel-ldraw-testkit/corpus/expected/pm82-data-textures.expected.json index abcc0f7..7d1b923 100644 --- a/crates/polymodel-ldraw-testkit/corpus/expected/pm82-data-textures.expected.json +++ b/crates/polymodel-ldraw-testkit/corpus/expected/pm82-data-textures.expected.json @@ -725,7 +725,28 @@ "state": "inactive" } ], - "texmap_geometry": [] + "texmap_geometry": [ + { + "line_type": 3, + "association": { + "fallback": false, + "glossmap": null, + "mode": "planar", + "parameters": [ + "0", + "0", + "0", + "10", + "0", + "0", + "0", + "10", + "0" + ], + "pngfile": "pm82-albedo.png" + } + } + ] }, "profile": "strict" } diff --git a/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-boundaries.expected.json b/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-boundaries.expected.json index 44f4e37..6f4b001 100644 --- a/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-boundaries.expected.json +++ b/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-boundaries.expected.json @@ -1256,7 +1256,7 @@ "10", "0" ], - "reference": "\"pm82", + "reference": "pm82 albedo.png", "state": "active" }, { @@ -1269,9 +1269,12 @@ "0", "10", "0", - "0" + "0", + "0", + "1", + "360" ], - "reference": "0", + "reference": "pm82 next.png", "state": "active" }, { @@ -1296,9 +1299,15 @@ "0", "0", "0", - "1" + "1", + "0", + "0", + "0", + "1", + "360", + "180" ], - "reference": "0", + "reference": "pm82 sphere.png", "state": "active" }, { @@ -1309,7 +1318,92 @@ "state": "inactive" } ], - "texmap_geometry": [] + "texmap_geometry": [ + { + "line_type": 3, + "association": { + "fallback": false, + "glossmap": null, + "mode": "planar", + "parameters": [ + "0", + "0", + "0", + "10", + "0", + "0", + "0", + "10", + "0" + ], + "pngfile": "pm82 albedo.png" + } + }, + { + "line_type": 4, + "association": { + "fallback": false, + "glossmap": null, + "mode": "cylindrical", + "parameters": [ + "0", + "0", + "0", + "0", + "10", + "0", + "0", + "0", + "1", + "360" + ], + "pngfile": "pm82 next.png" + } + }, + { + "line_type": 3, + "association": { + "fallback": true, + "glossmap": null, + "mode": "cylindrical", + "parameters": [ + "0", + "0", + "0", + "0", + "10", + "0", + "0", + "0", + "1", + "360" + ], + "pngfile": "pm82 next.png" + } + }, + { + "line_type": 5, + "association": { + "fallback": false, + "glossmap": null, + "mode": "spherical", + "parameters": [ + "0", + "0", + "0", + "0", + "1", + "0", + "0", + "0", + "1", + "360", + "180" + ], + "pngfile": "pm82 sphere.png" + } + } + ] }, "profile": "strict" } diff --git a/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-data-preview.expected.json b/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-data-preview.expected.json index f0a7734..7398c15 100644 --- a/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-data-preview.expected.json +++ b/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-data-preview.expected.json @@ -701,7 +701,28 @@ "state": "inactive" } ], - "texmap_geometry": [] + "texmap_geometry": [ + { + "line_type": 3, + "association": { + "fallback": false, + "glossmap": null, + "mode": "planar", + "parameters": [ + "0", + "0", + "0", + "10", + "0", + "0", + "0", + "10", + "0" + ], + "pngfile": "pm82-albedo.png" + } + } + ] }, "profile": "strict" } diff --git a/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-includes.expected.json b/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-includes.expected.json index debec7a..78f495d 100644 --- a/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-includes.expected.json +++ b/crates/polymodel-ldraw-testkit/corpus/expected/pm82-texmap-includes.expected.json @@ -977,30 +977,6 @@ "reference": "parent.png", "state": "active" }, - { - "kind": "end", - "mode": null, - "parameters": [], - "reference": null, - "state": "inactive" - }, - { - "kind": "start", - "mode": "planar", - "parameters": [ - "0", - "0", - "0", - "10", - "0", - "0", - "0", - "10", - "0" - ], - "reference": "child.png", - "state": "active" - }, { "kind": "end", "mode": null, diff --git a/crates/polymodel-renderer-protocol/src/scene.rs b/crates/polymodel-renderer-protocol/src/scene.rs index e0b75a7..2f9c0be 100644 --- a/crates/polymodel-renderer-protocol/src/scene.rs +++ b/crates/polymodel-renderer-protocol/src/scene.rs @@ -108,10 +108,17 @@ pub struct SceneGeometry { pub primitives: Vec, } +fn opaque_alpha() -> u8 { + u8::MAX +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneMaterial { pub id: MaterialId, pub colour: u32, + /// Resolved LDraw colour alpha; omitted by older scene payloads. + #[serde(default = "opaque_alpha")] + pub alpha: u8, pub edge_colour: Option, pub finish: Option, pub local_mapping: Option, diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index f70c956..92f171a 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -276,7 +276,7 @@ fn adapt_ldraw_assembly( Ok(id) }; let mut material_ids = - HashMap::<(u32, Option, Option), MaterialId>::new(); + HashMap::<(u32, u8, Option, Option), MaterialId>::new(); let mut materials = Vec::new(); for model in models { let provenance_id = ProvenanceId((provenance.len() + 1) as u64); @@ -449,8 +449,20 @@ fn adapt_ldraw_assembly( }) .unwrap_or(0) }; + let colour_alpha = |colour: u32| { + parsed + .and_then(|parsed| { + parsed + .result + .colours + .get(polymodel_ldraw_core::ColourCode(colour)) + }) + .and_then(|colour| colour.data.alpha) + .unwrap_or(u8::MAX) + }; let mut material_for = |colour: u32, texmap: Option<&SceneTexmapAssociation>| { let textures = &mut texture_for; + let alpha = colour_alpha(colour); let albedo = resources .is_some() .then_some(texmap) @@ -464,12 +476,13 @@ fn adapt_ldraw_assembly( .and_then(|texmap| texmap.glossmap.as_deref()) .map(|reference| textures(reference, "metallic_roughness")) .transpose()?; - let key = (colour, albedo, roughness); + let key = (colour, alpha, albedo, roughness); Ok::(*material_ids.entry(key).or_insert_with(|| { let id = MaterialId((materials.len() + 1) as u64); materials.push(SceneMaterial { id, colour, + alpha, edge_colour: None, finish: None, local_mapping: None, @@ -591,6 +604,29 @@ fn adapt_ldraw_assembly( let inherited_colour = occurrence.colour.map(u32::from).unwrap_or(16); let transform = world_transform(occurrence.transform)?; for (node, primitive) in batches { + let render_node = if let Some(texmap) = primitive.texmap.as_ref() { + let source_index = geometries + .iter() + .position(|geometry| geometry.id == GeometryId(node.0)) + .ok_or("occurrence geometry is missing")?; + let mut occurrence_geometry = geometries[source_index].clone(); + occurrence_geometry.id = GeometryId( + geometries + .iter() + .map(|geometry| geometry.id.0) + .max() + .unwrap_or(0) + .checked_add(1) + .ok_or("geometry id overflow")?, + ); + occurrence_geometry.uvs = + occurrence_texmap_uvs(texmap, transform, &occurrence_geometry.points)?; + let render_node = NodeId(occurrence_geometry.id.0); + geometries.push(occurrence_geometry); + render_node + } else { + *node + }; let effective_colour = match primitive.colour { 16 => inherited_colour, 24 => edge_colour_for(inherited_colour), @@ -602,7 +638,7 @@ fn adapt_ldraw_assembly( parent_occurrence_id: parent_occurrence_id.clone(), logical_part_occurrence_id: logical_part.as_ref().map(|(id, _)| id.clone()), logical_part_path: logical_part.as_ref().map(|(_, path)| path.clone()), - node: *node, + node: render_node, definition_id: metadata.definition_id.clone(), canonical_path: metadata.canonical_path.clone(), source_class: metadata.source_class.clone(), @@ -866,6 +902,43 @@ fn world_transform( ]) } +fn occurrence_texmap_uvs( + texmap: &SceneTexmapAssociation, + transform: [[f64; 4]; 4], + points: &[[f64; 3]], +) -> Result, &'static str> { + let mut parameters = texmap + .parameters + .iter() + .map(|value| value.parse::().map_err(|_| "invalid TEXMAP parameter")) + .collect::, _>>()?; + let control_points = 9; + let expected = match texmap.mode { + SceneTexmapProjectionMode::Planar => 9, + SceneTexmapProjectionMode::Cylindrical => 10, + SceneTexmapProjectionMode::Spherical => 11, + }; + if parameters.len() != expected { + return Err("invalid TEXMAP parameter count"); + } + for offset in (0..control_points).step_by(3) { + let source = [ + parameters[offset], + parameters[offset + 1], + parameters[offset + 2], + ]; + let transformed = apply_scene_transform(transform, ldraw_to_world(source))?; + parameters[offset..offset + 3].copy_from_slice(&transformed); + } + let transformed_points = points + .iter() + .copied() + .map(|point| apply_scene_transform(transform, point)) + .collect::, _>>()?; + texmap_uvs(texmap.mode, ¶meters, &transformed_points) + .map_err(|_| "invalid TEXMAP UV projection") +} + fn scene_transform_reflection(transform: [[f64; 4]; 4]) -> Option { let a = transform[0][0]; let b = transform[1][0]; @@ -3380,10 +3453,10 @@ impl NativeLights { } fn ldraw_colour(code: u32) -> Srgba { - ldraw_colour_with_policy(code, LdrawLightingMaterialPolicy::ldraw()) + ldraw_colour_with_policy(code, LdrawLightingMaterialPolicy::ldraw(), u8::MAX) } -fn ldraw_colour_with_policy(code: u32, policy: LdrawLightingMaterialPolicy) -> Srgba { +fn ldraw_colour_with_policy(code: u32, policy: LdrawLightingMaterialPolicy, alpha: u8) -> Srgba { let [r, g, b] = match code { 0 => policy.black_albedo, 1 => [0, 85, 191], @@ -3402,7 +3475,7 @@ fn ldraw_colour_with_policy(code: u32, policy: LdrawLightingMaterialPolicy) -> S 72 => [108, 110, 104], _ => [160, 165, 169], }; - Srgba::new_opaque(r, g, b) + Srgba::new(r, g, b, alpha) } pub type NativeSceneModel = Vec>; @@ -4605,8 +4678,9 @@ fn build_scene_model_from_batches( let scene_material = index.material(scene, batch.material)?; let colour = scene_material.colour; let policy = LdrawLightingMaterialPolicy::ldraw(); + let alpha = scene_material.alpha; let mut cpu_material = CpuMaterial { - albedo: ldraw_colour_with_policy(colour, policy), + albedo: ldraw_colour_with_policy(colour, policy, alpha), roughness: if colour == 0 { policy.black_roughness } else { @@ -4615,7 +4689,7 @@ fn build_scene_model_from_batches( metallic: policy.metallic, ..Default::default() }; - let mut translucent = false; + let mut translucent = alpha < u8::MAX; if let Some(texture_id) = scene_material.albedo_texture { let texture = scene .textures diff --git a/crates/polymodel-renderer-worker/src/tests.rs b/crates/polymodel-renderer-worker/src/tests.rs index 691682b..90d92e0 100644 --- a/crates/polymodel-renderer-worker/src/tests.rs +++ b/crates/polymodel-renderer-worker/src/tests.rs @@ -1,5 +1,31 @@ use super::*; +#[cfg(not(target_arch = "wasm32"))] +fn encoded_png( + width: u32, + height: u32, + color: png::ColorType, + depth: png::BitDepth, + pixels: &[u8], + palette: Option<&[u8]>, + transparency: Option<&[u8]>, +) -> Vec { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(std::io::Cursor::new(&mut bytes), width, height); + encoder.set_color(color); + encoder.set_depth(depth); + if let Some(palette) = palette { + encoder.set_palette(palette); + } + if let Some(transparency) = transparency { + encoder.set_trns(transparency); + } + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(pixels).unwrap(); + drop(writer); + bytes +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn glossmap_decode_uses_alpha_not_rgb() { @@ -74,6 +100,82 @@ fn albedo_decode_expands_rgb_and_preserves_alpha() { assert!(translucent); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn glossmap_decode_matrix_covers_png_channel_forms() { + let rgb = encoded_png( + 1, + 1, + png::ColorType::Rgb, + png::BitDepth::Eight, + &[1, 2, 3], + None, + None, + ); + assert_eq!(decode_glossmap_png(&rgb).unwrap().alpha, vec![255]); + let gray = encoded_png( + 1, + 1, + png::ColorType::Grayscale, + png::BitDepth::Eight, + &[9], + None, + None, + ); + assert_eq!(decode_glossmap_png(&gray).unwrap().alpha, vec![255]); + let indexed = encoded_png( + 1, + 1, + png::ColorType::Indexed, + png::BitDepth::Eight, + &[0], + Some(&[1, 2, 3]), + None, + ); + assert_eq!(decode_glossmap_png(&indexed).unwrap().alpha, vec![255]); + let indexed_trns = encoded_png( + 1, + 1, + png::ColorType::Indexed, + png::BitDepth::Eight, + &[0], + Some(&[1, 2, 3]), + Some(&[77]), + ); + assert_eq!(decode_glossmap_png(&indexed_trns).unwrap().alpha, vec![77]); + let ga = encoded_png( + 1, + 1, + png::ColorType::GrayscaleAlpha, + png::BitDepth::Eight, + &[9, 64], + None, + None, + ); + assert_eq!(decode_glossmap_png(&ga).unwrap().alpha, vec![64]); + let rgba16 = encoded_png( + 1, + 1, + png::ColorType::Rgba, + png::BitDepth::Sixteen, + &[0, 1, 0, 2, 0, 3, 0, 4], + None, + None, + ); + assert_eq!(decode_glossmap_png(&rgba16).unwrap().alpha, vec![4]); + let ga16 = encoded_png( + 1, + 1, + png::ColorType::GrayscaleAlpha, + png::BitDepth::Sixteen, + &[0, 9, 0, 10], + None, + None, + ); + assert_eq!(decode_glossmap_png(&ga16).unwrap().alpha, vec![10]); + assert!(decode_glossmap_png(b"not a png").is_err()); +} + #[test] fn gloss_roughness_packing_uses_alpha_only_and_checked_half_up() { assert_eq!(packed_roughness_from_alpha(0, 255).unwrap(), [0, 255, 0]); @@ -509,6 +611,7 @@ fn picker_scene() -> Scene { materials: vec![SceneMaterial { id: MaterialId(1), colour: 4, + alpha: 255, edge_colour: None, finish: None, local_mapping: None, @@ -1655,10 +1758,13 @@ fn ldraw_black_material_is_readable_but_remains_black() { assert_eq!(policy.metallic, 0.0); assert!(policy.black_roughness > policy.roughness); assert_eq!( - ldraw_colour_with_policy(0, policy), + ldraw_colour_with_policy(0, policy, u8::MAX), three_d::Srgba::new_opaque(28, 38, 40) ); - assert_eq!(ldraw_colour(0), ldraw_colour_with_policy(0, policy)); + assert_eq!( + ldraw_colour(0), + ldraw_colour_with_policy(0, policy, u8::MAX) + ); } #[test] diff --git a/crates/polymodel-renderer-worker/src/texture.rs b/crates/polymodel-renderer-worker/src/texture.rs index fee5570..75ef2a8 100644 --- a/crates/polymodel-renderer-worker/src/texture.rs +++ b/crates/polymodel-renderer-worker/src/texture.rs @@ -232,10 +232,7 @@ pub fn texmap_uvs( ); let tangent = cross(axis_n, radial_n); let angle = dot(projected, tangent).atan2(dot(projected, radial_n)); - [ - clamp01(angle / angle_extent + 0.5), - clamp01(axial / axis_len), - ] + [angle / angle_extent + 0.5, clamp01(axial / axis_len)] }) .collect::>() } @@ -268,7 +265,7 @@ pub fn texmap_uvs( let direction = normalize(sub(*point, p1)).unwrap_or(normal); let longitude = dot(direction, horizontal).atan2(dot(direction, normal)); let latitude = dot(direction, vertical).asin(); - [clamp01(longitude / a + 0.5), clamp01(latitude / b + 0.5)] + [longitude / a + 0.5, clamp01(latitude / b + 0.5)] }) .collect::>() } @@ -276,9 +273,27 @@ pub fn texmap_uvs( if result.iter().flatten().any(|value| !value.is_finite()) { return Err("TEXMAP generated non-finite UV".to_owned()); } + let mut result = result; + if matches!( + mode, + SceneTexmapProjectionMode::Cylindrical | SceneTexmapProjectionMode::Spherical + ) { + let mut reference = result.first().map(|uv| uv[0]).unwrap_or(0.0); + for uv in result.iter_mut() { + while uv[0] - reference > 0.5 { + uv[0] -= 1.0; + } + while reference - uv[0] > 0.5 { + uv[0] += 1.0; + } + reference = uv[0]; + } + } result .into_iter() .map(|[u, v]| { + let u = clamp01(u); + let v = clamp01(v); let u = u as f32; let v = v as f32; (u.is_finite() && v.is_finite()) -- knotmirror 0.0.1