Another project
0

Configure Feed

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

feat(render): hidden-line edge render & goldens

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

author
Lewis
date (Jun 6, 2026, 1:33 AM +0300) commit c3ec5e84 parent 195f1178 change-id xszuyunr
+392 -13
+48 -13
crates/bone-render/src/lib.rs
··· 20 20 EntityKindTag, PickAperture, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker, 21 21 }; 22 22 pub use pipelines::{ 23 - ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, GlyphPipeline, GridPipeline, 24 - LinesPipeline, SdfGlyphInstance, SolidPipeline, SolidView, TextPipeline, 23 + ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, Edge3dPipeline, 24 + EdgeProjection, EdgeView, FaceFill, GlyphPipeline, GridPipeline, LinesPipeline, 25 + SdfGlyphInstance, SolidPipeline, SolidView, TextPipeline, 25 26 }; 26 27 pub use preview::{PreviewArc, PreviewCircle, SketchPreview}; 27 28 pub use scene::{ 28 - RelationGlyphKind, SceneArc, SceneCircle, SceneDimension, SceneLine, ScenePoint, 29 - SceneRelationGlyph, SketchScene, SolidScene, 29 + EdgeScene, GenuineEdge, RelationGlyphKind, SceneArc, SceneCircle, SceneDimension, SceneLine, 30 + ScenePoint, SceneRelationGlyph, SilhouetteCandidate, SketchScene, SolidScene, 30 31 }; 31 32 pub use snapshot::{ 32 33 ClearColor, GlyphStyle, GridStyle, SnapshotFrame, StrokeStyle, Style, TextStyle, decode_png, ··· 181 182 #[derive(Debug)] 182 183 pub struct SolidRenderer { 183 184 solid: SolidPipeline, 185 + edges: Edge3dPipeline, 184 186 depth: Option<DepthTarget>, 185 187 shading: bone_types::ShadingModel, 186 188 } ··· 196 198 pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 197 199 Self { 198 200 solid: SolidPipeline::new(gpu, color_format), 201 + edges: Edge3dPipeline::new(gpu, color_format), 199 202 depth: None, 200 203 shading: bone_types::ShadingModel::Gouraud, 201 204 } ··· 214 217 camera: bone_types::Camera3, 215 218 style: &Style, 216 219 ) -> Result<SnapshotFrame> { 220 + self.render_with_edges( 221 + ctx, 222 + scene, 223 + &EdgeScene::empty(), 224 + camera, 225 + style, 226 + FaceFill::Shaded, 227 + ) 228 + } 229 + 230 + pub fn render_with_edges( 231 + &mut self, 232 + ctx: &OffscreenContext, 233 + scene: &SolidScene, 234 + edges: &EdgeScene, 235 + camera: bone_types::Camera3, 236 + style: &Style, 237 + fill: FaceFill, 238 + ) -> Result<SnapshotFrame> { 217 239 let extent = ctx.extent(); 218 240 let clip_from_world = camera3::clip_from_world(camera, extent)?; 219 241 let (ex, ey, ez) = camera.eye().coords_mm(); ··· 231 253 .texture 232 254 .create_view(&wgpu::TextureViewDescriptor::default()); 233 255 let solid = &self.solid; 234 - let view = SolidView { 256 + let edge_pipeline = &self.edges; 257 + let solid_view = SolidView { 235 258 clip_from_world, 236 259 eye_world, 237 260 shading: self.shading, 261 + fill, 262 + }; 263 + let edge_view = EdgeView { 264 + clip_from_world, 265 + projection: EdgeProjection::from_camera(camera), 266 + viewport_px: [ 267 + lower_f32(f64::from(extent.width().value())), 268 + lower_f32(f64::from(extent.height().value())), 269 + ], 270 + crease_threshold_rad: CREASE_THRESHOLD_RAD, 271 + dash_period_px: EDGE_DASH_DISABLED_PX, 272 + dash_on_ratio: 0.0, 238 273 }; 239 274 ctx.render(|encoder, color_view, pick_view| { 240 - solid.draw( 241 - encoder, 242 - RenderTargets::new(color_view, pick_view), 243 - &depth_view, 244 - scene, 245 - view, 246 - style, 247 - ); 275 + let targets = RenderTargets::new(color_view, pick_view); 276 + solid.draw(encoder, targets, &depth_view, scene, solid_view, style); 277 + if !edges.is_empty() { 278 + edge_pipeline.draw(encoder, targets, &depth_view, edges, edge_view); 279 + } 248 280 }) 249 281 } 250 282 } 283 + 284 + const CREASE_THRESHOLD_RAD: f64 = core::f64::consts::FRAC_PI_6; 285 + const EDGE_DASH_DISABLED_PX: f32 = 0.0; 251 286 252 287 fn depth_texture(device: &wgpu::Device, extent: ViewportExtent) -> wgpu::Texture { 253 288 device.create_texture(&wgpu::TextureDescriptor {
+344
crates/bone-render/tests/edges.rs
··· 1 + use bone_kernel::{ 2 + BrepSolid, Circle2, Curve2Kind, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, 3 + ExtrudeProfile, ExtrudeSense, Line2, MergeResult, ProfileEdge, ProfileLoop, SolidMesh, 4 + evaluate_extrude, 5 + }; 6 + use bone_render::{ 7 + EdgeScene, FaceFill, OffscreenContext, SnapshotFrame, SolidRenderer, SolidScene, Style, 8 + frame_isometric, 9 + }; 10 + use bone_types::{ 11 + Aabb3, Angle, AngleTolerance, Camera3, ChordHeightTolerance, FeatureId, Length, Plane3, Point2, 12 + Point3, PositiveLength, Projection, SketchEntityId, SketchId, Tolerance, UnitVec3, millimeter, 13 + radian, 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_EDGES_GOLDENS"; 23 + const CHORD_MM: f64 = 0.05; 24 + const ANGLE_RAD: 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 blind(depth_mm: f64) -> ExtrudeFeature { 39 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 40 + panic!("{depth_mm} mm is a positive length"); 41 + }; 42 + ExtrudeFeature { 43 + sketch: SketchId::null(), 44 + direction: ExtrudeDirection::Normal { 45 + sense: ExtrudeSense::Forward, 46 + }, 47 + end_condition: ExtrudeEndCondition::Blind { depth }, 48 + draft: None, 49 + thin_wall: None, 50 + merge_result: MergeResult::Merge, 51 + } 52 + } 53 + 54 + fn unit_cube() -> BrepSolid { 55 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 56 + let mut features: SlotMap<FeatureId, ()> = SlotMap::with_key(); 57 + let corners = [ 58 + Point2::from_mm(0.0, 0.0), 59 + Point2::from_mm(1.0, 0.0), 60 + Point2::from_mm(1.0, 1.0), 61 + Point2::from_mm(0.0, 1.0), 62 + ]; 63 + let edges = (0..4) 64 + .map(|index| { 65 + let start = corners[index]; 66 + let end = corners[(index + 1) % 4]; 67 + let Ok(segment) = Line2::new(start, end, TOLERANCE) else { 68 + panic!("rectangle endpoints are distinct"); 69 + }; 70 + ProfileEdge::new( 71 + Curve2Kind::Line(segment), 72 + entities.insert(()), 73 + entities.insert(()), 74 + ) 75 + }) 76 + .collect(); 77 + let profile = ExtrudeProfile::new(xy_plane(), vec![ProfileLoop::Open(edges)]); 78 + let Ok(solid) = evaluate_extrude(features.insert(()), &profile, &blind(1.0)) else { 79 + panic!("the unit square extrudes into a cube"); 80 + }; 81 + solid 82 + } 83 + 84 + fn cylinder() -> BrepSolid { 85 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 86 + let mut features: SlotMap<FeatureId, ()> = SlotMap::with_key(); 87 + let Ok(disk) = Circle2::new( 88 + Point2::from_mm(0.0, 0.0), 89 + Length::new::<millimeter>(5.0), 90 + TOLERANCE, 91 + ) else { 92 + panic!("circle radius is positive"); 93 + }; 94 + let profile = ExtrudeProfile::new( 95 + xy_plane(), 96 + vec![ProfileLoop::Closed { 97 + curve: Curve2Kind::Circle(disk), 98 + curve_entity: entities.insert(()), 99 + }], 100 + ); 101 + let Ok(solid) = evaluate_extrude(features.insert(()), &profile, &blind(10.0)) else { 102 + panic!("the disk extrudes into a cylinder"); 103 + }; 104 + solid 105 + } 106 + 107 + fn mesh_of(solid: &BrepSolid) -> SolidMesh { 108 + let Ok(mesh) = solid.tessellate( 109 + ChordHeightTolerance::from_mm(CHORD_MM), 110 + AngleTolerance::from_radians(ANGLE_RAD), 111 + ) else { 112 + panic!("the solid tessellates"); 113 + }; 114 + mesh 115 + } 116 + 117 + fn scenes(solid: &BrepSolid) -> (SolidScene, EdgeScene) { 118 + let mesh = mesh_of(solid); 119 + let Ok(faces) = SolidScene::from_mesh(&mesh) else { 120 + panic!("the mesh packs face pick ids"); 121 + }; 122 + let Ok(edges) = EdgeScene::from_solid(solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM)) 123 + else { 124 + panic!("the solid packs edge pick ids"); 125 + }; 126 + (faces, edges) 127 + } 128 + 129 + fn aabb_of(solid: &BrepSolid) -> Aabb3 { 130 + let Some(aabb) = solid.bounding_box() else { 131 + panic!("the solid has a bounding box"); 132 + }; 133 + aabb 134 + } 135 + 136 + fn direction(x: f64, y: f64, z: f64) -> UnitVec3 { 137 + let Ok(unit) = UnitVec3::try_from_components(x, y, z, TOLERANCE) else { 138 + panic!("({x}, {y}, {z}) is a nonzero direction"); 139 + }; 140 + unit 141 + } 142 + 143 + fn framed(aabb: Aabb3, from: UnitVec3, up: UnitVec3) -> Camera3 { 144 + let center = aabb.center(); 145 + let span = 0.5 * aabb.extent().norm_mm(); 146 + let eye = center + from.into_vec(Length::new::<millimeter>(span * 3.0)); 147 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(span * 1.2)) else { 148 + panic!("half height is positive"); 149 + }; 150 + let Ok(camera) = Camera3::new(eye, center, up, projection) else { 151 + panic!("camera is non-degenerate"); 152 + }; 153 + camera 154 + } 155 + 156 + fn cube_camera(solid: &BrepSolid, size: bone_render::ViewportExtent) -> Camera3 { 157 + let Ok(camera) = frame_isometric(aabb_of(solid), size) else { 158 + panic!("the cube frames isometrically"); 159 + }; 160 + camera 161 + } 162 + 163 + fn framed_perspective(aabb: Aabb3, from: UnitVec3, up: UnitVec3) -> Camera3 { 164 + let center = aabb.center(); 165 + let span = 0.5 * aabb.extent().norm_mm(); 166 + let eye = center + from.into_vec(Length::new::<millimeter>(span * 4.0)); 167 + let Ok(projection) = Projection::perspective( 168 + Angle::new::<radian>(core::f64::consts::FRAC_PI_4), 169 + Length::new::<millimeter>(span), 170 + Length::new::<millimeter>(span * 12.0), 171 + ) else { 172 + panic!("the field of view is within (0, pi)"); 173 + }; 174 + let Ok(camera) = Camera3::new(eye, center, up, projection) else { 175 + panic!("camera is non-degenerate"); 176 + }; 177 + camera 178 + } 179 + 180 + fn render( 181 + ctx: &OffscreenContext, 182 + faces: &SolidScene, 183 + edges: &EdgeScene, 184 + camera: Camera3, 185 + fill: FaceFill, 186 + ) -> SnapshotFrame { 187 + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); 188 + let Ok(frame) = renderer.render_with_edges(ctx, faces, edges, camera, &Style::default(), fill) 189 + else { 190 + panic!("SolidRenderer::render_with_edges failed"); 191 + }; 192 + frame 193 + } 194 + 195 + #[test] 196 + fn cube_shaded_with_edges_matches_golden() { 197 + let size = extent(256); 198 + let ctx = make_context(size); 199 + let solid = unit_cube(); 200 + let (faces, edges) = scenes(&solid); 201 + let frame = render( 202 + &ctx, 203 + &faces, 204 + &edges, 205 + cube_camera(&solid, size), 206 + FaceFill::Shaded, 207 + ); 208 + check_golden( 209 + &frame, 210 + "tests/goldens/shaded_with_edges_256.png", 211 + UPDATE_ENV, 212 + ); 213 + } 214 + 215 + #[test] 216 + fn cube_hidden_line_removed_matches_golden() { 217 + let size = extent(256); 218 + let ctx = make_context(size); 219 + let solid = unit_cube(); 220 + let (faces, edges) = scenes(&solid); 221 + let frame = render( 222 + &ctx, 223 + &faces, 224 + &edges, 225 + cube_camera(&solid, size), 226 + FaceFill::Occluder, 227 + ); 228 + check_golden( 229 + &frame, 230 + "tests/goldens/hidden_line_removed_256.png", 231 + UPDATE_ENV, 232 + ); 233 + } 234 + 235 + #[test] 236 + fn cube_perspective_with_edges_matches_golden() { 237 + let size = extent(256); 238 + let ctx = make_context(size); 239 + let solid = unit_cube(); 240 + let (faces, edges) = scenes(&solid); 241 + let camera = framed_perspective( 242 + aabb_of(&solid), 243 + direction(1.0, 1.0, 0.6), 244 + UnitVec3::z_axis(), 245 + ); 246 + let frame = render(&ctx, &faces, &edges, camera, FaceFill::Shaded); 247 + check_golden( 248 + &frame, 249 + "tests/goldens/perspective_with_edges_256.png", 250 + UPDATE_ENV, 251 + ); 252 + } 253 + 254 + #[test] 255 + fn cylinder_shaded_with_edges_matches_golden() { 256 + let size = extent(256); 257 + let ctx = make_context(size); 258 + let solid = cylinder(); 259 + let (faces, edges) = scenes(&solid); 260 + let camera = framed( 261 + aabb_of(&solid), 262 + direction(1.0, 1.0, 0.6), 263 + UnitVec3::z_axis(), 264 + ); 265 + let frame = render(&ctx, &faces, &edges, camera, FaceFill::Shaded); 266 + check_golden( 267 + &frame, 268 + "tests/goldens/shaded_with_edges_cylinder_256.png", 269 + UPDATE_ENV, 270 + ); 271 + } 272 + 273 + #[test] 274 + fn cylinder_hidden_line_removed_matches_golden() { 275 + let size = extent(256); 276 + let ctx = make_context(size); 277 + let solid = cylinder(); 278 + let (faces, edges) = scenes(&solid); 279 + let camera = framed( 280 + aabb_of(&solid), 281 + direction(1.0, 1.0, 0.6), 282 + UnitVec3::z_axis(), 283 + ); 284 + let frame = render(&ctx, &faces, &edges, camera, FaceFill::Occluder); 285 + check_golden( 286 + &frame, 287 + "tests/goldens/hidden_line_removed_cylinder_256.png", 288 + UPDATE_ENV, 289 + ); 290 + } 291 + 292 + #[test] 293 + fn cube_genuine_edges_classify_as_creases() { 294 + let solid = unit_cube(); 295 + let mesh = mesh_of(&solid); 296 + let Ok(edges) = EdgeScene::from_solid(&solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM)) 297 + else { 298 + panic!("the cube packs edge pick ids"); 299 + }; 300 + assert!(!edges.genuine().is_empty(), "the cube emits genuine edges"); 301 + let right_angle = core::f64::consts::FRAC_PI_2; 302 + assert!( 303 + edges 304 + .genuine() 305 + .iter() 306 + .all(|edge| (edge.crease().radians() - right_angle).abs() < 1.0e-6), 307 + "every cube edge is a 90-degree crease", 308 + ); 309 + } 310 + 311 + #[test] 312 + fn cube_flat_faces_emit_no_silhouette_candidates() { 313 + let solid = unit_cube(); 314 + let mesh = mesh_of(&solid); 315 + let Ok(edges) = EdgeScene::from_solid(&solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM)) 316 + else { 317 + panic!("the cube packs edge pick ids"); 318 + }; 319 + assert!( 320 + edges.silhouettes().is_empty(), 321 + "coplanar interior edges never straddle, so a flat-faced cube yields no silhouette candidates", 322 + ); 323 + } 324 + 325 + #[test] 326 + fn cylinder_side_yields_silhouette_candidates() { 327 + let solid = cylinder(); 328 + let mesh = mesh_of(&solid); 329 + let Ok(edges) = EdgeScene::from_solid(&solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM)) 330 + else { 331 + panic!("the cylinder packs edge pick ids"); 332 + }; 333 + assert!( 334 + !edges.silhouettes().is_empty(), 335 + "the tessellated cylinder side yields silhouette candidates", 336 + ); 337 + assert!( 338 + edges 339 + .silhouettes() 340 + .iter() 341 + .any(|candidate| candidate.normal_a() != candidate.normal_b()), 342 + "neighboring side facets disagree in orientation so a silhouette can form", 343 + ); 344 + }
crates/bone-render/tests/goldens/hidden_line_removed_256.png

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.