Another project
1use bone_kernel::{
2 BrepEdge, BrepFace, BrepSolid, BrepVertex, Circle2, Curve2Kind, ExtrudeDirection,
3 ExtrudeEndCondition, ExtrudeFeature, ExtrudeProfile, ExtrudeSense, MergeResult, ProfileLoop,
4 evaluate_extrude,
5};
6use bone_render::{
7 OffscreenContext, PickId, PickIndex, PickQuery, PickedItem, SnapshotFrame, SolidRenderer,
8 SolidScene, Style, ViewportPx,
9};
10use bone_types::{
11 Aabb3, AngleTolerance, Camera3, ChordHeightTolerance, FeatureId, Length, Plane3, Point2,
12 Point3, PositiveLength, Projection, ShadingModel, SketchEntityId, SketchId, Tolerance,
13 UnitVec3, millimeter,
14};
15use slotmap::{Key, SlotMap};
16
17mod common;
18
19use common::{check_golden, extent_square as extent, make_context};
20
21const TOLERANCE: Tolerance = Tolerance::new(1.0e-9);
22const UPDATE_ENV: &str = "BONE_UPDATE_SOLID_GOLDENS";
23const CHORD: f64 = 0.05;
24const ANGLE: f64 = 0.2;
25
26fn 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
38fn 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
52fn 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
68fn 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
77fn 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
82fn 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
89fn 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
96fn 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
109fn 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
122fn 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
130fn 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]
138fn 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]
155fn 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]
164fn 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]
207fn 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}