feat(solid3d): ISOLINES and silhouettes on sphere and torus faces

Extends the curved-face wireframe treatment from cones to spheres and tori, so
a solid built from those faces reads the way a cylinder already did.

ISOLINES: sphere faces get meridian lines across the face's colatitude span at
evenly spaced longitudes; torus faces get minor cross-section circles at evenly
spaced revolution angles. Both clip to the face's own parametric window (from
the boundary polygon via a shared `angular_span` gap-finder), so a partial
sphere or torus arc doesn't draw lines out over the missing part.

Silhouettes (DISPSILH): `CurvedGen` becomes an enum. A sphere silhouettes as
the great circle perpendicular to the view, clipped to the face window; a torus
as the two curves where the tube turns edge-on, traced around the revolution.

The silhouette now uses the camera's view *direction* alone, not the eye
position — a single parallel direction for the whole scene — so the outline
follows the view angle, holds still under pan, and doesn't foreshorten, which
is the orthographic silhouette a CAD wireframe expects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-17 23:30:05 +03:00
commit 8c72a06790
4 changed files with 458 additions and 128 deletions

View file

@ -1587,72 +1587,142 @@ impl Pipeline {
&mut self,
device: &wgpu::Device,
sets: &[crate::scene::model::mesh_model::MeshLodSet],
eye: glam::DVec3,
view_dir: glam::Vec3,
) {
// Silhouettes follow the view *angle* only — a single parallel direction
// for the whole scene, not the eye-to-surface vector — so the outline
// stays put under pan and doesn't foreshorten. This is the orthographic
// silhouette a CAD wireframe expects.
let view = glam::DVec3::new(view_dir.x as f64, view_dir.y as f64, view_dir.z as f64)
.normalize_or(glam::DVec3::NEG_Z);
use crate::scene::model::mesh_model::CurvedGen;
use crate::scene::pipeline::mesh_gpu::MeshVertex;
let mut verts: Vec<MeshVertex> = Vec::new();
let dot = |a: [f32; 3], b: glam::DVec3| {
a[0] as f64 * b.x + a[1] as f64 * b.y + a[2] as f64 * b.z
let d3 = |a: [f32; 3]| glam::DVec3::new(a[0] as f64, a[1] as f64, a[2] as f64);
let lo = |c: [f32; 3], l: [f32; 3]| {
glam::DVec3::new(
c[0] as f64 + l[0] as f64,
c[1] as f64 + l[1] as f64,
c[2] as f64 + l[2] as f64,
)
};
for set in sets {
let color = set.lods.first().map(|m| m.color).unwrap_or([0.0, 0.0, 0.0, 1.0]);
let mut mk = |w: glam::DVec3| -> MeshVertex {
let (hx, hy, hz) = (w.x as f32, w.y as f32, w.z as f32);
MeshVertex {
position: [hx, hy, hz],
normal: [0.0, 1.0, 0.0],
color,
position_low: [
(w.x - hx as f64) as f32,
(w.y - hy as f64) as f32,
(w.z - hz as f64) as f32,
],
}
};
for g in &set.curved_gens {
let base = glam::DVec3::new(
g.base[0] as f64 + g.base_low[0] as f64,
g.base[1] as f64 + g.base_low[1] as f64,
g.base[2] as f64 + g.base_low[2] as f64,
);
// View direction from eye toward the face (accurate for both
// orthographic and perspective).
let view = (base - eye).normalize();
let Some((t0, t1)) = silhouette_thetas(
dot(g.u_dir, view),
dot(g.v_dir, view),
dot(g.axis, view),
g.tan_a as f64,
) else {
continue;
};
let r0 = g.radius as f64;
let r1 = g.radius as f64 + g.h_max as f64 * g.tan_a as f64;
for theta in [t0, t1] {
if !g.full {
// Only within the face's own arc; normalise the offset
// from theta_min into [0, TAU).
let mut off = theta - g.theta_min as f64;
let tau = std::f64::consts::TAU;
off = off.rem_euclid(tau);
if off > g.theta_span as f64 {
match g {
CurvedGen::Cone {
base, base_low, axis, u_dir, v_dir, radius, tan_a,
h_max, theta_min, theta_span, full,
} => {
let base = lo(*base, *base_low);
let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir));
let Some((t0, t1)) =
silhouette_thetas(view.dot(u), view.dot(v), view.dot(axis), *tan_a as f64)
else {
continue;
};
let r0 = *radius as f64;
let r1 = *radius as f64 + *h_max as f64 * *tan_a as f64;
for theta in [t0, t1] {
if !full {
let off = (theta - *theta_min as f64).rem_euclid(std::f64::consts::TAU);
if off > *theta_span as f64 {
continue;
}
}
let (c, s) = (theta.cos(), theta.sin());
let radial = u * c + v * s;
verts.push(mk(base + radial * r0));
verts.push(mk(base + radial * r1 + axis * *h_max as f64));
}
}
let (c, s) = (theta.cos(), theta.sin());
let radial = |r: f64| {
[
g.u_dir[0] as f64 * r * c + g.v_dir[0] as f64 * r * s,
g.u_dir[1] as f64 * r * c + g.v_dir[1] as f64 * r * s,
g.u_dir[2] as f64 * r * c + g.v_dir[2] as f64 * r * s,
]
};
let rb = radial(r0);
let rt = radial(r1);
let mk = |off: [f64; 3], h: f64| -> MeshVertex {
let wx = base.x + off[0] + h * g.axis[0] as f64;
let wy = base.y + off[1] + h * g.axis[1] as f64;
let wz = base.z + off[2] + h * g.axis[2] as f64;
let (hx, hy, hz) = (wx as f32, wy as f32, wz as f32);
MeshVertex {
position: [hx, hy, hz],
normal: [0.0, 1.0, 0.0],
color: set.lods.first().map(|m| m.color).unwrap_or([0.0, 0.0, 0.0, 1.0]),
position_low: [
(wx - hx as f64) as f32,
(wy - hy as f64) as f32,
(wz - hz as f64) as f32,
],
CurvedGen::Sphere {
center, center_low, pole, u_dir, v_dir, radius,
theta_min, theta_span, full, phi_min, phi_max,
} => {
let c = lo(*center, *center_low);
let (pole, u, v) = (d3(*pole), d3(*u_dir), d3(*v_dir));
let r = *radius as f64;
// Great circle in the plane perpendicular to the view.
let mut e1 = view.cross(pole);
if e1.length_squared() < 1e-12 {
e1 = view.cross(u);
}
};
verts.push(mk(rb, 0.0));
verts.push(mk(rt, g.h_max as f64));
let e1 = e1.normalize();
let e2 = view.cross(e1).normalize();
const N: usize = 64;
let mut prev: Option<glam::DVec3> = None;
for i in 0..=N {
let a = std::f64::consts::TAU * (i as f64 / N as f64);
let dir = e1 * a.cos() + e2 * a.sin();
// Keep only the arc that lies on the actual face.
let on_face = *full || {
let phi = dir.dot(pole).clamp(-1.0, 1.0).acos();
let th = dir.dot(v).atan2(dir.dot(u));
let toff = (th - *theta_min as f64).rem_euclid(std::f64::consts::TAU);
phi >= *phi_min as f64
&& phi <= *phi_max as f64
&& (toff <= *theta_span as f64)
};
let p = if on_face { Some(c + dir * r) } else { None };
if let (Some(a), Some(b)) = (prev, p) {
verts.push(mk(a));
verts.push(mk(b));
}
prev = p;
}
}
CurvedGen::Torus {
center, center_low, axis, u_dir, v_dir, major, minor,
phi_min, phi_span, full,
} => {
let ctr = lo(*center, *center_low);
let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir));
let (major, minor) = (*major as f64, *minor as f64);
// True silhouette: at each revolution angle the tube is a
// circle; the two points where its normal turns edge-on
// trace two curves around the ring. Sample the revolution
// and connect consecutive edge-on points.
const N: usize = 72;
let span = if *full { std::f64::consts::TAU } else { *phi_span as f64 };
let mut prev: [Option<glam::DVec3>; 2] = [None, None];
for i in 0..=N {
let phi = *phi_min as f64 + span * (i as f64 / N as f64);
let radial = u * phi.cos() + v * phi.sin();
let ring = ctr + radial * major;
let (rv, av) = (radial.dot(view), axis.dot(view));
if rv.abs() < 1e-9 && av.abs() < 1e-9 {
prev = [None, None];
continue;
}
// tube normal(θ) = radial·cosθ + axis·sinθ; ⟂ view at
// θ = atan2(-rv, av) and +π.
let th = (-rv).atan2(av);
let cur = [th, th + std::f64::consts::PI];
for k in 0..2 {
let t = cur[k];
let p = ring + (radial * t.cos() + axis * t.sin()) * minor;
if let Some(pp) = prev[k] {
verts.push(mk(pp));
verts.push(mk(p));
}
prev[k] = Some(p);
}
}
}
}
}
}