Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(render): solid pass pick ids & shading

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 5, 2026, 7:26 PM +0300) commit 66dc5bcd parent 7a7f5369 change-id kkzsrznk
+442 -102
+1 -1
crates/bone-render/src/gpu.rs
··· 308 308 }); 309 309 } 310 310 311 - fn pick_clear_color() -> wgpu::Color { 311 + pub(crate) fn pick_clear_color() -> wgpu::Color { 312 312 wgpu::Color { 313 313 r: f64::from(PickId::NONE.raw()), 314 314 g: 0.0,
+19 -4
crates/bone-render/src/lib.rs
··· 21 21 }; 22 22 pub use pipelines::{ 23 23 ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, GlyphPipeline, GridPipeline, 24 - LinesPipeline, SdfGlyphInstance, SolidPipeline, TextPipeline, 24 + LinesPipeline, SdfGlyphInstance, SolidPipeline, SolidView, TextPipeline, 25 25 }; 26 26 pub use preview::{PreviewArc, PreviewCircle, SketchPreview}; 27 27 pub use scene::{ ··· 182 182 pub struct SolidRenderer { 183 183 solid: SolidPipeline, 184 184 depth: Option<DepthTarget>, 185 + shading: bone_types::ShadingModel, 185 186 } 186 187 187 188 #[derive(Debug)] ··· 196 197 Self { 197 198 solid: SolidPipeline::new(gpu, color_format), 198 199 depth: None, 200 + shading: bone_types::ShadingModel::Gouraud, 199 201 } 200 202 } 201 203 204 + #[must_use] 205 + pub const fn with_shading_model(mut self, shading: bone_types::ShadingModel) -> Self { 206 + self.shading = shading; 207 + self 208 + } 209 + 202 210 pub fn render( 203 211 &mut self, 204 212 ctx: &OffscreenContext, ··· 208 216 ) -> Result<SnapshotFrame> { 209 217 let extent = ctx.extent(); 210 218 let clip_from_world = camera3::clip_from_world(camera, extent)?; 219 + let (ex, ey, ez) = camera.eye().coords_mm(); 220 + let eye_world = [lower_f32(ex), lower_f32(ey), lower_f32(ez)]; 211 221 if !matches!(&self.depth, Some(target) if target.extent == extent) { 212 222 self.depth = Some(DepthTarget { 213 223 extent, ··· 221 231 .texture 222 232 .create_view(&wgpu::TextureViewDescriptor::default()); 223 233 let solid = &self.solid; 224 - ctx.render(|encoder, color_view, _pick_view| { 234 + let view = SolidView { 235 + clip_from_world, 236 + eye_world, 237 + shading: self.shading, 238 + }; 239 + ctx.render(|encoder, color_view, pick_view| { 225 240 solid.draw( 226 241 encoder, 227 - color_view, 242 + RenderTargets::new(color_view, pick_view), 228 243 &depth_view, 229 244 scene, 230 - clip_from_world, 245 + view, 231 246 style, 232 247 ); 233 248 })
+1 -1
crates/bone-render/src/pipelines/mod.rs
··· 14 14 pub use glyph::GlyphPipeline; 15 15 pub use grid::GridPipeline; 16 16 pub use lines::LinesPipeline; 17 - pub use solid::SolidPipeline; 17 + pub use solid::{SolidPipeline, SolidView}; 18 18 pub use text::TextPipeline; 19 19 20 20 use crate::camera::Camera2;
+68 -22
crates/bone-render/src/pipelines/solid.rs
··· 1 1 use wgpu::util::DeviceExt; 2 2 3 - use crate::gpu::Gpu; 3 + use bone_types::ShadingModel; 4 + 5 + use crate::RenderTargets; 6 + use crate::gpu::{Gpu, PICK_FORMAT, pick_clear_color}; 4 7 use crate::lower_f32; 5 8 use crate::scene::SolidScene; 6 9 use crate::snapshot::Style; ··· 11 14 const BASE_COLOR: [f32; 4] = [0.72, 0.74, 0.78, 1.0]; 12 15 const AMBIENT: f32 = 0.28; 13 16 17 + const SHADING_DEFAULT: u32 = 0; 18 + const SHADING_PHONG: u32 = 1; 19 + 20 + const fn shading_code(model: ShadingModel) -> u32 { 21 + match model { 22 + ShadingModel::Phong => SHADING_PHONG, 23 + ShadingModel::Flat | ShadingModel::Gouraud => SHADING_DEFAULT, 24 + } 25 + } 26 + 27 + #[derive(Copy, Clone)] 28 + pub struct SolidView { 29 + pub clip_from_world: [f32; 16], 30 + pub eye_world: [f32; 3], 31 + pub shading: ShadingModel, 32 + } 33 + 14 34 #[repr(C)] 15 35 #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 16 36 struct SolidVertex { 17 37 position: [f32; 3], 18 38 normal: [f32; 3], 39 + pick: u32, 19 40 } 20 41 21 42 const VERTEX_STRIDE: u64 = core::mem::size_of::<SolidVertex>() as u64; ··· 26 47 clip_from_world: [f32; 16], 27 48 light_dir: [f32; 4], 28 49 base_color: [f32; 4], 50 + eye_world: [f32; 4], 29 51 ambient: f32, 30 - pad: [f32; 3], 52 + shading_model: u32, 53 + pad: [f32; 2], 31 54 } 32 55 33 56 const UNIFORM_SIZE: u64 = core::mem::size_of::<SolidUniform>() as u64; 34 57 35 - const VERTEX_ATTRS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![ 58 + const VERTEX_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![ 36 59 0 => Float32x3, 37 60 1 => Float32x3, 61 + 2 => Uint32, 38 62 ]; 39 63 40 64 pub struct SolidPipeline { ··· 89 113 module: &shader, 90 114 entry_point: Some("fs"), 91 115 compilation_options: wgpu::PipelineCompilationOptions::default(), 92 - targets: &[Some(wgpu::ColorTargetState { 93 - format: color_format, 94 - blend: None, 95 - write_mask: wgpu::ColorWrites::ALL, 96 - })], 116 + targets: &[ 117 + Some(wgpu::ColorTargetState { 118 + format: color_format, 119 + blend: None, 120 + write_mask: wgpu::ColorWrites::ALL, 121 + }), 122 + Some(wgpu::ColorTargetState { 123 + format: PICK_FORMAT, 124 + blend: None, 125 + write_mask: wgpu::ColorWrites::ALL, 126 + }), 127 + ], 97 128 }), 98 129 primitive: wgpu::PrimitiveState { 99 130 topology: wgpu::PrimitiveTopology::TriangleList, ··· 141 172 pub fn draw( 142 173 &self, 143 174 encoder: &mut wgpu::CommandEncoder, 144 - color_view: &wgpu::TextureView, 175 + targets: RenderTargets<'_>, 145 176 depth_view: &wgpu::TextureView, 146 177 scene: &SolidScene, 147 - clip_from_world: [f32; 16], 178 + view: SolidView, 148 179 style: &Style, 149 180 ) { 150 181 let uniform = SolidUniform { 151 - clip_from_world, 182 + clip_from_world: view.clip_from_world, 152 183 light_dir: LIGHT_DIR, 153 184 base_color: BASE_COLOR, 185 + eye_world: [view.eye_world[0], view.eye_world[1], view.eye_world[2], 1.0], 154 186 ambient: AMBIENT, 155 - pad: [0.0; 3], 187 + shading_model: shading_code(view.shading), 188 + pad: [0.0; 2], 156 189 }; 157 190 self.queue 158 191 .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); ··· 160 193 let indices = build_indices(scene); 161 194 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 162 195 label: Some("bone-render:solid-pass"), 163 - color_attachments: &[Some(wgpu::RenderPassColorAttachment { 164 - view: color_view, 165 - resolve_target: None, 166 - depth_slice: None, 167 - ops: wgpu::Operations { 168 - load: wgpu::LoadOp::Clear(style.background().into()), 169 - store: wgpu::StoreOp::Store, 170 - }, 171 - })], 196 + color_attachments: &[ 197 + Some(wgpu::RenderPassColorAttachment { 198 + view: targets.color, 199 + resolve_target: None, 200 + depth_slice: None, 201 + ops: wgpu::Operations { 202 + load: wgpu::LoadOp::Clear(style.background().into()), 203 + store: wgpu::StoreOp::Store, 204 + }, 205 + }), 206 + Some(wgpu::RenderPassColorAttachment { 207 + view: targets.pick, 208 + resolve_target: None, 209 + depth_slice: None, 210 + ops: wgpu::Operations { 211 + load: wgpu::LoadOp::Clear(pick_clear_color()), 212 + store: wgpu::StoreOp::Store, 213 + }, 214 + }), 215 + ], 172 216 depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { 173 217 view: depth_view, 174 218 depth_ops: Some(wgpu::Operations { ··· 220 264 .positions() 221 265 .iter() 222 266 .zip(scene.normals()) 223 - .map(|(point, normal)| { 267 + .zip(scene.pick_ids()) 268 + .map(|((point, normal), pick)| { 224 269 let (px, py, pz) = point.coords_mm(); 225 270 let (nx, ny, nz) = normal.components(); 226 271 SolidVertex { 227 272 position: [lower_f32(px), lower_f32(py), lower_f32(pz)], 228 273 normal: [lower_f32(nx), lower_f32(ny), lower_f32(nz)], 274 + pick: pick.raw(), 229 275 } 230 276 }) 231 277 .collect()
+39 -6
crates/bone-render/src/pipelines/solid.wgsl
··· 2 2 clip_from_world: mat4x4<f32>, 3 3 light_dir: vec4<f32>, 4 4 base_color: vec4<f32>, 5 + eye_world: vec4<f32>, 5 6 ambient: f32, 7 + shading_model: u32, 6 8 pad0: f32, 7 9 pad1: f32, 8 - pad2: f32, 9 10 }; 10 11 12 + const SHADING_PHONG: u32 = 1u; 13 + const SPECULAR_STRENGTH: f32 = 0.35; 14 + const SHININESS: f32 = 48.0; 15 + 11 16 @group(0) @binding(0) var<uniform> u: Uniform; 12 17 13 18 struct VsOut { 14 19 @builtin(position) clip: vec4<f32>, 15 20 @location(0) world_normal: vec3<f32>, 21 + @location(1) world_pos: vec3<f32>, 22 + @location(2) @interpolate(flat) pick_id: u32, 23 + }; 24 + 25 + struct FsOut { 26 + @location(0) color: vec4<f32>, 27 + @location(1) pick_id: u32, 16 28 }; 17 29 18 30 @vertex 19 - fn vs(@location(0) position: vec3<f32>, @location(1) normal: vec3<f32>) -> VsOut { 31 + fn vs( 32 + @location(0) position: vec3<f32>, 33 + @location(1) normal: vec3<f32>, 34 + @location(2) pick_id: u32, 35 + ) -> VsOut { 20 36 var out: VsOut; 21 37 out.clip = u.clip_from_world * vec4<f32>(position, 1.0); 22 38 out.world_normal = normal; 39 + out.world_pos = position; 40 + out.pick_id = pick_id; 23 41 return out; 24 42 } 25 43 26 44 @fragment 27 - fn fs(in: VsOut) -> @location(0) vec4<f32> { 45 + fn fs(in: VsOut) -> FsOut { 28 46 let n = normalize(in.world_normal); 29 47 let l = normalize(u.light_dir.xyz); 30 - let lambert = max(dot(n, l), 0.0); 31 - let shade = u.ambient + (1.0 - u.ambient) * lambert; 32 - return vec4<f32>(u.base_color.rgb * shade, u.base_color.a); 48 + let ndl = dot(n, l); 49 + let lambert = max(ndl, 0.0); 50 + let wrap = 0.5 * ndl + 0.5; 51 + let ambient = u.ambient * wrap * wrap; 52 + let shade = ambient + (1.0 - u.ambient) * lambert; 53 + var rgb = u.base_color.rgb * shade; 54 + 55 + if (u.shading_model == SHADING_PHONG) { 56 + let view = normalize(u.eye_world.xyz - in.world_pos); 57 + let reflection = reflect(-l, n); 58 + let specular = pow(max(dot(view, reflection), 0.0), SHININESS) * SPECULAR_STRENGTH; 59 + rgb = rgb + vec3<f32>(specular); 60 + } 61 + 62 + var out: FsOut; 63 + out.color = vec4<f32>(rgb, u.base_color.a); 64 + out.pick_id = in.pick_id; 65 + return out; 33 66 }
+23 -13
crates/bone-render/src/scene.rs
··· 624 624 pub struct SolidScene { 625 625 positions: Vec<Point3>, 626 626 normals: Vec<UnitVec3>, 627 + pick_ids: Vec<PickId>, 627 628 triangles: Vec<[u32; 3]>, 628 629 } 629 630 ··· 633 634 Self { 634 635 positions: Vec::new(), 635 636 normals: Vec::new(), 637 + pick_ids: Vec::new(), 636 638 triangles: Vec::new(), 637 639 } 638 640 } 639 641 640 - #[must_use] 641 - pub fn from_mesh(mesh: &SolidMesh) -> Self { 642 - mesh.faces().iter().fold(Self::empty(), |mut scene, face| { 643 - let Ok(base) = u32::try_from(scene.positions.len()) else { 644 - panic!("solid mesh vertex count fits a u32 index"); 645 - }; 646 - scene.positions.extend_from_slice(face.positions()); 647 - scene.normals.extend_from_slice(face.normals()); 648 - scene 649 - .triangles 650 - .extend(face.triangles().iter().map(|tri| tri.map(|i| i + base))); 651 - scene 652 - }) 642 + pub fn from_mesh(mesh: &SolidMesh) -> Result<Self, PickIdError> { 643 + mesh.faces() 644 + .iter() 645 + .try_fold(Self::empty(), |mut scene, face| { 646 + let Ok(base) = u32::try_from(scene.positions.len()) else { 647 + panic!("solid mesh vertex count fits a u32 index"); 648 + }; 649 + let pick = PickId::brep_face(face.face())?; 650 + scene.positions.extend_from_slice(face.positions()); 651 + scene.normals.extend_from_slice(face.normals()); 652 + scene.pick_ids.extend(face.positions().iter().map(|_| pick)); 653 + scene 654 + .triangles 655 + .extend(face.triangles().iter().map(|tri| tri.map(|i| i + base))); 656 + Ok(scene) 657 + }) 653 658 } 654 659 655 660 #[must_use] ··· 660 665 #[must_use] 661 666 pub fn normals(&self) -> &[UnitVec3] { 662 667 &self.normals 668 + } 669 + 670 + #[must_use] 671 + pub fn pick_ids(&self) -> &[PickId] { 672 + &self.pick_ids 663 673 } 664 674 665 675 #[must_use]
+54 -1
crates/bone-render/tests/common/mod.rs
··· 1 - use bone_render::{OffscreenContext, RenderError, ViewportExtent, ViewportPx}; 1 + #![allow( 2 + dead_code, 3 + reason = "dead_code checks each test binary alone and misses helpers used only by sibling binaries" 4 + )] 5 + 6 + use std::path::PathBuf; 7 + 8 + use bone_render::{ 9 + OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, SnapshotFrame, ViewportExtent, 10 + ViewportPx, decode_png, encode_png, 11 + }; 2 12 3 13 const ADAPTER_RETRIES: u32 = 3; 4 14 const ADAPTER_RETRY_BACKOFF_MS: u64 = 100; 15 + const GOLDEN_DIFF_TOLERANCE: f64 = 16.0 / 255.0; 5 16 6 17 #[must_use] 7 18 pub fn extent_square(side: u32) -> ViewportExtent { ··· 35 46 None => unreachable!("retry loop always populates last_err on failure"), 36 47 } 37 48 } 49 + 50 + pub fn check_golden(frame: &SnapshotFrame, golden_rel: &str, update_env: &str) { 51 + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(golden_rel); 52 + if std::env::var(update_env).is_ok() { 53 + let Ok(bytes) = encode_png(frame) else { 54 + panic!("encode_png failed"); 55 + }; 56 + if let Some(parent) = path.parent() { 57 + assert!( 58 + std::fs::create_dir_all(parent).is_ok(), 59 + "failed to create goldens dir" 60 + ); 61 + } 62 + assert!( 63 + std::fs::write(&path, &bytes).is_ok(), 64 + "failed to write golden {}", 65 + path.display() 66 + ); 67 + return; 68 + } 69 + let Ok(bytes) = std::fs::read(&path) else { 70 + panic!( 71 + "golden missing at {}: rerun with {update_env}=1 to generate", 72 + path.display() 73 + ); 74 + }; 75 + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 76 + panic!("failed to decode golden PNG"); 77 + }; 78 + assert_eq!(golden_extent, frame.extent(), "golden extent drift"); 79 + let threshold = PixelDiffThreshold::new(GOLDEN_DIFF_TOLERANCE); 80 + let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else { 81 + panic!("PixelDiff rejected inputs"); 82 + }; 83 + assert!( 84 + report.is_clean(), 85 + "{golden_rel} drifted from golden: {} mismatches, worst {:?}, backend {}", 86 + report.over_threshold(), 87 + report.worst(), 88 + frame.backend(), 89 + ); 90 + }
+6 -54
crates/bone-render/tests/cube_camera.rs
··· 1 - use std::path::PathBuf; 2 - 3 1 use bone_kernel::{ 4 2 BrepSolid, Curve2Kind, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeProfile, 5 3 ExtrudeSense, Line2, MergeResult, ProfileEdge, ProfileLoop, evaluate_extrude, 6 4 }; 7 - use bone_render::{ 8 - PixelDiff, PixelDiffThreshold, SolidRenderer, SolidScene, Style, decode_png, encode_png, 9 - frame_isometric, 10 - }; 5 + use bone_render::{SolidRenderer, SolidScene, Style, frame_isometric}; 11 6 use bone_types::{ 12 7 AngleTolerance, ChordHeightTolerance, FeatureId, Length, Plane3, Point2, Point3, 13 8 PositiveLength, SketchEntityId, SketchId, Tolerance, UnitVec3, millimeter, ··· 16 11 17 12 mod common; 18 13 19 - use common::{extent_square as extent, make_context}; 14 + use common::{check_golden, extent_square as extent, make_context}; 20 15 21 16 const GOLDEN: &str = "tests/goldens/cube_iso_256.png"; 22 17 const UPDATE_ENV: &str = "BONE_UPDATE_CUBE_ISO_GOLDEN"; 23 - const DIFF_TOLERANCE: f64 = 16.0 / 255.0; 24 18 const TOLERANCE: Tolerance = Tolerance::new(1.0e-9); 25 - 26 - fn golden_path() -> PathBuf { 27 - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) 28 - } 29 19 30 20 fn unit_cube() -> BrepSolid { 31 21 let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); ··· 90 80 ) else { 91 81 panic!("the cube tessellates"); 92 82 }; 93 - let scene = SolidScene::from_mesh(&mesh); 83 + let Ok(scene) = SolidScene::from_mesh(&mesh) else { 84 + panic!("the cube mesh packs face pick ids"); 85 + }; 94 86 let Some(aabb) = solid.bounding_box() else { 95 87 panic!("the cube has a bounding box"); 96 88 }; ··· 103 95 panic!("SolidRenderer::render failed"); 104 96 }; 105 97 106 - let path = golden_path(); 107 - 108 - if std::env::var(UPDATE_ENV).is_ok() { 109 - let Ok(bytes) = encode_png(&frame) else { 110 - panic!("encode_png failed"); 111 - }; 112 - if let Some(parent) = path.parent() { 113 - assert!( 114 - std::fs::create_dir_all(parent).is_ok(), 115 - "failed to create goldens dir" 116 - ); 117 - } 118 - assert!( 119 - std::fs::write(&path, &bytes).is_ok(), 120 - "failed to write golden {}", 121 - path.display() 122 - ); 123 - return; 124 - } 125 - 126 - let Ok(bytes) = std::fs::read(&path) else { 127 - panic!( 128 - "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 129 - path.display() 130 - ); 131 - }; 132 - let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 133 - panic!("failed to decode golden PNG"); 134 - }; 135 - assert_eq!(golden_extent, size, "golden extent drift"); 136 - let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); 137 - let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { 138 - panic!("PixelDiff rejected inputs"); 139 - }; 140 - assert!( 141 - report.is_clean(), 142 - "cube render drifted from golden: {} mismatches, worst {:?}, backend {}", 143 - report.over_threshold(), 144 - report.worst(), 145 - frame.backend(), 146 - ); 98 + check_golden(&frame, GOLDEN, UPDATE_ENV); 147 99 }
crates/bone-render/tests/goldens/cube_iso_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/solid_cylinder_front_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/solid_donut_top_256.png

This is a binary file and will not be displayed.

+231
crates/bone-render/tests/solid_pipeline.rs
··· 1 + use bone_kernel::{ 2 + BrepEdge, BrepFace, BrepSolid, BrepVertex, Circle2, Curve2Kind, ExtrudeDirection, 3 + ExtrudeEndCondition, ExtrudeFeature, ExtrudeProfile, ExtrudeSense, MergeResult, ProfileLoop, 4 + evaluate_extrude, 5 + }; 6 + use bone_render::{ 7 + OffscreenContext, PickId, PickIndex, PickQuery, PickedItem, SnapshotFrame, SolidRenderer, 8 + SolidScene, Style, ViewportPx, 9 + }; 10 + use bone_types::{ 11 + Aabb3, AngleTolerance, Camera3, ChordHeightTolerance, FeatureId, Length, Plane3, Point2, 12 + Point3, PositiveLength, Projection, ShadingModel, SketchEntityId, SketchId, Tolerance, 13 + UnitVec3, millimeter, 14 + }; 15 + use slotmap::{Key, SlotMap}; 16 + 17 + mod common; 18 + 19 + use common::{check_golden, extent_square as extent, make_context}; 20 + 21 + const TOLERANCE: Tolerance = Tolerance::new(1.0e-9); 22 + const UPDATE_ENV: &str = "BONE_UPDATE_SOLID_GOLDENS"; 23 + const CHORD: f64 = 0.05; 24 + const ANGLE: f64 = 0.2; 25 + 26 + fn xy_plane() -> Plane3 { 27 + let Ok(plane) = Plane3::new( 28 + Point3::origin(), 29 + UnitVec3::x_axis(), 30 + UnitVec3::y_axis(), 31 + TOLERANCE, 32 + ) else { 33 + panic!("x and y axes are orthonormal"); 34 + }; 35 + plane 36 + } 37 + 38 + fn circle_loop(entities: &mut SlotMap<SketchEntityId, ()>, radius_mm: f64) -> ProfileLoop { 39 + let Ok(disk) = Circle2::new( 40 + Point2::from_mm(0.0, 0.0), 41 + Length::new::<millimeter>(radius_mm), 42 + TOLERANCE, 43 + ) else { 44 + panic!("circle radius is positive"); 45 + }; 46 + ProfileLoop::Closed { 47 + curve: Curve2Kind::Circle(disk), 48 + curve_entity: entities.insert(()), 49 + } 50 + } 51 + 52 + fn blind(depth_mm: f64) -> ExtrudeFeature { 53 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 54 + panic!("{depth_mm} mm is a positive length"); 55 + }; 56 + ExtrudeFeature { 57 + sketch: SketchId::null(), 58 + direction: ExtrudeDirection::Normal { 59 + sense: ExtrudeSense::Forward, 60 + }, 61 + end_condition: ExtrudeEndCondition::Blind { depth }, 62 + draft: None, 63 + thin_wall: None, 64 + merge_result: MergeResult::Merge, 65 + } 66 + } 67 + 68 + fn solid_from(loops: Vec<ProfileLoop>, depth_mm: f64) -> BrepSolid { 69 + let mut features: SlotMap<FeatureId, ()> = SlotMap::with_key(); 70 + let profile = ExtrudeProfile::new(xy_plane(), loops); 71 + let Ok(solid) = evaluate_extrude(features.insert(()), &profile, &blind(depth_mm)) else { 72 + panic!("the profile and feature describe a buildable extrude"); 73 + }; 74 + solid 75 + } 76 + 77 + fn cylinder() -> BrepSolid { 78 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 79 + solid_from(vec![circle_loop(&mut entities, 5.0)], 10.0) 80 + } 81 + 82 + fn donut() -> BrepSolid { 83 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 84 + let outer = circle_loop(&mut entities, 10.0); 85 + let inner = circle_loop(&mut entities, 4.0); 86 + solid_from(vec![outer, inner], 6.0) 87 + } 88 + 89 + fn direction(x: f64, y: f64, z: f64) -> UnitVec3 { 90 + let Ok(unit) = UnitVec3::try_from_components(x, y, z, TOLERANCE) else { 91 + panic!("({x}, {y}, {z}) is a nonzero direction"); 92 + }; 93 + unit 94 + } 95 + 96 + fn framed(aabb: Aabb3, from: UnitVec3, up: UnitVec3) -> Camera3 { 97 + let center = aabb.center(); 98 + let span = 0.5 * aabb.extent().norm_mm(); 99 + let eye = center + from.into_vec(Length::new::<millimeter>(span * 3.0)); 100 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(span * 1.2)) else { 101 + panic!("half height is positive"); 102 + }; 103 + let Ok(camera) = Camera3::new(eye, center, up, projection) else { 104 + panic!("camera is non-degenerate"); 105 + }; 106 + camera 107 + } 108 + 109 + fn mesh_scene(solid: &BrepSolid) -> SolidScene { 110 + let Ok(mesh) = solid.tessellate( 111 + ChordHeightTolerance::from_mm(CHORD), 112 + AngleTolerance::from_radians(ANGLE), 113 + ) else { 114 + panic!("the solid tessellates"); 115 + }; 116 + let Ok(scene) = SolidScene::from_mesh(&mesh) else { 117 + panic!("the mesh packs face pick ids"); 118 + }; 119 + scene 120 + } 121 + 122 + fn render(ctx: &OffscreenContext, scene: &SolidScene, camera: Camera3) -> SnapshotFrame { 123 + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); 124 + let Ok(frame) = renderer.render(ctx, scene, camera, &Style::default()) else { 125 + panic!("SolidRenderer::render failed"); 126 + }; 127 + frame 128 + } 129 + 130 + fn aabb_of(solid: &BrepSolid) -> Aabb3 { 131 + let Some(aabb) = solid.bounding_box() else { 132 + panic!("the solid has a bounding box"); 133 + }; 134 + aabb 135 + } 136 + 137 + #[test] 138 + fn cylinder_front_matches_golden() { 139 + let ctx = make_context(extent(256)); 140 + let solid = cylinder(); 141 + let camera = framed( 142 + aabb_of(&solid), 143 + direction(1.0, 1.0, 0.0), 144 + UnitVec3::z_axis(), 145 + ); 146 + let frame = render(&ctx, &mesh_scene(&solid), camera); 147 + check_golden( 148 + &frame, 149 + "tests/goldens/solid_cylinder_front_256.png", 150 + UPDATE_ENV, 151 + ); 152 + } 153 + 154 + #[test] 155 + fn donut_top_matches_golden() { 156 + let ctx = make_context(extent(256)); 157 + let solid = donut(); 158 + let camera = framed(aabb_of(&solid), UnitVec3::z_axis(), UnitVec3::y_axis()); 159 + let frame = render(&ctx, &mesh_scene(&solid), camera); 160 + check_golden(&frame, "tests/goldens/solid_donut_top_256.png", UPDATE_ENV); 161 + } 162 + 163 + #[test] 164 + fn solid_pass_writes_unpackable_face_pick_ids() { 165 + let ctx = make_context(extent(256)); 166 + let solid = cylinder(); 167 + let scene = mesh_scene(&solid); 168 + let camera = framed( 169 + aabb_of(&solid), 170 + direction(1.0, 1.0, 0.0), 171 + UnitVec3::z_axis(), 172 + ); 173 + let _frame = render(&ctx, &scene, camera); 174 + 175 + let Ok(index) = PickIndex::build_solid( 176 + solid.iter_faces().map(BrepFace::id), 177 + solid.iter_edges().map(BrepEdge::id), 178 + solid.iter_vertices().map(BrepVertex::id), 179 + ) else { 180 + panic!("the solid's faces, edges, and vertices build a pick index"); 181 + }; 182 + let picker = ctx.picker(index.clone()); 183 + 184 + let Ok(corner) = picker.raw_at(PickQuery::new(ViewportPx::new(3), ViewportPx::new(3))) else { 185 + panic!("the corner pixel reads back"); 186 + }; 187 + assert_eq!( 188 + corner, 189 + PickId::NONE, 190 + "background must clear to PickId::NONE, got {corner}", 191 + ); 192 + 193 + let Ok(center) = picker.raw_at(PickQuery::new(ViewportPx::new(128), ViewportPx::new(128))) 194 + else { 195 + panic!("the center pixel reads back"); 196 + }; 197 + let Some(item) = center.unpack(&index) else { 198 + panic!("center pick {center} did not resolve against the solid"); 199 + }; 200 + assert!( 201 + matches!(item, PickedItem::BrepFace(_)), 202 + "the center of the cylinder must pick a B-rep face, got {item:?}", 203 + ); 204 + } 205 + 206 + #[test] 207 + fn phong_variant_adds_specular_highlight() { 208 + let ctx = make_context(extent(128)); 209 + let solid = cylinder(); 210 + let scene = mesh_scene(&solid); 211 + let camera = framed( 212 + aabb_of(&solid), 213 + direction(-0.302, -0.503, 0.809), 214 + UnitVec3::z_axis(), 215 + ); 216 + let style = Style::default(); 217 + let mut lit = SolidRenderer::new(ctx.gpu(), ctx.color_format()); 218 + let mut phong = 219 + SolidRenderer::new(ctx.gpu(), ctx.color_format()).with_shading_model(ShadingModel::Phong); 220 + let Ok(default_frame) = lit.render(&ctx, &scene, camera, &style) else { 221 + panic!("default render failed"); 222 + }; 223 + let Ok(phong_frame) = phong.render(&ctx, &scene, camera, &style) else { 224 + panic!("phong render failed"); 225 + }; 226 + assert_ne!( 227 + default_frame.rgba(), 228 + phong_frame.rgba(), 229 + "the Phong specular highlight must change the shaded output", 230 + ); 231 + }