From 431dc43e43288670c4beb6c9ad923f548f35304e Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:28:02 +0300 Subject: [PATCH] 3D and Materials: twisted Extrude & Bevel walls keep their faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A twist bends the strip between two slices into a saddle; its normal came from one corner's triangle, which leaned so far back that from about 60° of twist every wall was taken for facing away and dropped, leaving the front cap alone. Each strip now takes the whole quad's average normal (Newell's), as Inflate's facets do. A test checks the faces kept against each strip's average normal oriented away from the axis, for twists up to 300° and four views. --- crates/three-d/src/solid.rs | 7 ++--- crates/three-d/src/tests_solid.rs | 51 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index c1ae0217..272ab1db 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -464,10 +464,9 @@ impl Builder<'_> { for i in 0..n { let j = (i + 1) % n; let quad = vec![a[i], a[j], b[j], b[i]]; - let mut normal = quad[1].sub(quad[0]).cross(quad[2].sub(quad[0])); - if normal.dot(normal) < 1e-18 { - normal = quad[2].sub(quad[0]).cross(quad[3].sub(quad[0])); - } + // A twist bends the strip between two slices into a saddle: its normal is the whole + // quad's average (Newell's), not one corner's triangle, which leans far to one side. + let mut normal = newell(std::slice::from_ref(&quad)); if normal.dot(normal) < 1e-18 { continue; } diff --git a/crates/three-d/src/tests_solid.rs b/crates/three-d/src/tests_solid.rs index 0f16f190..b003897f 100644 --- a/crates/three-d/src/tests_solid.rs +++ b/crates/three-d/src/tests_solid.rs @@ -373,3 +373,54 @@ fn twist_holds_between_0_and_360_degrees() { assert_eq!(at(500.0), at(360.0)); assert_ne!(at(45.0), at(0.0)); } + +/// A twisted extrusion shows every wall facing the viewer: a twist bends each strip between two +/// slices into a saddle, and its normal from one corner's triangle leaned so far back that from +/// 60° on every wall was dropped. Checked against each strip's average normal, oriented away from +/// the square's axis. +#[test] +fn twisted_walls_face_the_way_they_turn() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let c = b.center(); + for twist in [60.0, 120.0, 180.0, 300.0] { + for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] { + let v = View { rotation_x: x, rotation_y: y, ..front() }; + let o = Extrude { twist, ..Extrude::default() }; + let got = extrude(&p, b, o, v).unwrap(); + // Expected: walls (and caps) whose outward normal faces the viewer. + let o = o.sanitized(); + let camera = Camera::new(v, b, o.depth); + let ring = &rings(&p, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0]; + let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64); + let z = |t: f64| o.depth / 2.0 - o.depth * t; + let mut expected = 0; + for k in 0..slices { + let (t0, t1) = (k as f64 / slices as f64, (k + 1) as f64 / slices as f64); + let (r0, r1) = (section(ring, b, &o, t0), section(ring, b, &o, t1)); + for i in 0..ring.len() { + let j = (i + 1) % ring.len(); + let q = [camera.place(r0[i], z(t0)), camera.place(r0[j], z(t0)), camera.place(r1[j], z(t1)), camera.place(r1[i], z(t1))]; + let mut n = newell(&[q.to_vec()]).unit(); + let mid = r0[i].midpoint(r1[j]); + let away = camera.rotate(V3(mid.x - c.x, mid.y - c.y, 0.0)); + if n.dot(away) < 0.0 { + n = V3(-n.0, -n.1, -n.2); + } + let centre = V3((q[0].0 + q[2].0) / 2.0, (q[0].1 + q[2].1) / 2.0, (q[0].2 + q[2].2) / 2.0); + // Kept as the builder keeps them: facing, or seen almost edge-on, with an area. + let projected: Vec = q.iter().map(|v| camera.project(*v)).collect(); + if camera.facing(centre, n) > -0.02 && signed_area(&projected).abs() >= 1e-10 { + expected += 1; + } + } + } + for (n, t) in [(1.0, 0.0), (-1.0, 1.0)] { + let cap: Vec = section(ring, b, &o, t).iter().map(|p| camera.project(camera.place(*p, z(t)))).collect(); + if camera.facing(V3::default(), camera.rotate(V3(0.0, 0.0, n))) > -0.02 && signed_area(&cap).abs() >= 1e-10 { + expected += 1; + } + } + assert_eq!(got.len(), expected, "twist {twist}, view ({x}, {y})"); + } + } +}