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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-17 23:30:05 +03:00
1 parent ae5231427b
commit 8c72a06790
4 files changed
+458 -128

No files matched your search

+273 -46
View File
@@ -267,33 +267,195 @@ fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option<ConeFaceGeom> {
})
}
/// Pick `count` parameter values across `[t_min, t_min + span]`. A closed
/// revolution (`full`) is divided into `count` values around the full turn (the
/// line at `t` and `t + span` coincide); a bounded arc gets `count` interior
/// values, its two ends already drawn as rim edges.
fn iso_params(t_min: f64, span: f64, full: bool, count: usize) -> Vec<f64> {
(0..count)
.map(|k| {
if full {
t_min + span * (k as f64 / count as f64)
} else {
t_min + span * ((k as f64 + 1.0) / (count as f64 + 1.0))
}
})
.collect()
}
fn collect_isolines(sat: &SatDocument, count: usize) -> Vec<[f64; 3]> {
if count == 0 {
return Vec::new();
}
let mut out: Vec<[f64; 3]> = Vec::new();
for face in sat.faces() {
let Some(g) = cone_face_geom(sat, &face) else {
let Some(surf) = sat.resolve(face.surface()) else {
continue;
};
let [cx, cy, cz] = g.center;
let (r0, r1) = (g.radius + g.h_min * g.tan_a, g.radius + g.h_max * g.tan_a);
// A closed rim's line at theta and theta+TAU coincide, so step across
// [0, TAU) with `count` divisions; a bounded arc gets interior lines
// spanning its own arc (its two ends are already drawn as rim edges).
for k in 0..count {
let a = if g.full {
g.theta_min + g.theta_span * (k as f64 / count as f64)
} else {
g.theta_min + g.theta_span * ((k as f64 + 1.0) / (count as f64 + 1.0))
};
out.push(cone_pt(cx, cy, cz, g.axis, g.u_dir, g.v_dir, r0, a, g.h_min));
out.push(cone_pt(cx, cy, cz, g.axis, g.u_dir, g.v_dir, r1, a, g.h_max));
match surf.entity_type.as_str() {
"cone-surface" => cone_isolines(sat, &face, count, &mut out),
"sphere-surface" => sphere_isolines(sat, &face, count, &mut out),
"torus-surface" => torus_isolines(sat, &face, count, &mut out),
_ => {}
}
}
out
}
/// Longitudinal lines up a cone/cylinder face, bottom rim to top rim.
fn cone_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec<[f64; 3]>) {
let Some(g) = cone_face_geom(sat, face) else {
return;
};
let [cx, cy, cz] = g.center;
let (r0, r1) = (g.radius + g.h_min * g.tan_a, g.radius + g.h_max * g.tan_a);
for a in iso_params(g.theta_min, g.theta_span, g.full, count) {
out.push(cone_pt(cx, cy, cz, g.axis, g.u_dir, g.v_dir, r0, a, g.h_min));
out.push(cone_pt(cx, cy, cz, g.axis, g.u_dir, g.v_dir, r1, a, g.h_max));
}
}
/// Meridian lines on a sphere face — the standard "how a sphere reads" isolines,
/// each running pole-ward across the face's colatitude span at `count` evenly
/// spaced longitudes within the face's own longitude span.
fn sphere_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec<[f64; 3]>) {
let Some(surf) = sat.resolve(face.surface()) else {
return;
};
let Some(sphere) = SatSphereSurface::from_record(surf) else {
return;
};
let (cx, cy, cz) = sphere.center();
let r = sphere.radius();
let pole = norm3([sphere.pole().0, sphere.pole().1, sphere.pole().2]);
let u = norm3([
sphere.u_direction().0,
sphere.u_direction().1,
sphere.u_direction().2,
]);
let v = cross3(pole, u);
let poly = collect_face_polygon(sat, face, 48);
let (theta_min, theta_span, full, phi_min, phi_max) =
sphere_param_range(&poly, [cx, cy, cz], pole, u, v);
const M: usize = 24;
let sphere_pt = |theta: f64, phi: f64| {
let d = sphere_dir(pole, u, v, theta, phi);
[cx + r * d[0], cy + r * d[1], cz + r * d[2]]
};
for theta in iso_params(theta_min, theta_span, full, count) {
for m in 0..M {
let p0 = phi_min + (phi_max - phi_min) * (m as f64 / M as f64);
let p1 = phi_min + (phi_max - phi_min) * ((m + 1) as f64 / M as f64);
out.push(sphere_pt(theta, p0));
out.push(sphere_pt(theta, p1));
}
}
}
/// Minor (cross-section) circles on a torus face at `count` revolution angles
/// spanning the face — how a torus tube reads.
fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec<[f64; 3]>) {
let Some(surf) = sat.resolve(face.surface()) else {
return;
};
let Some(torus) = SatTorusSurface::from_record(surf) else {
return;
};
let (cx, cy, cz) = torus.center();
let axis = norm3([torus.normal().0, torus.normal().1, torus.normal().2]);
let u = norm3([
torus.u_direction().0,
torus.u_direction().1,
torus.u_direction().2,
]);
let v = cross3(axis, u);
let major = torus.major_radius();
let minor = torus.minor_radius();
let poly = collect_face_polygon(sat, face, 48);
let (phi_min, phi_span, full) = torus_phi_range(&poly, [cx, cy, cz], u, v);
const M: usize = 20;
for phi in iso_params(phi_min, phi_span, full, count) {
for t in 0..M {
let t0 = TAU * (t as f64 / M as f64);
let t1 = TAU * ((t + 1) as f64 / M as f64);
out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t0, phi));
out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t1, phi));
}
}
}
/// Longitude/colatitude span of a sphere face from its boundary polygon.
/// Returns `(theta_min, theta_span, full, phi_min, phi_max)`; an empty boundary
/// (a lone full sphere) spans the whole surface.
fn sphere_param_range(
poly: &[[f64; 3]],
center: [f64; 3],
pole: [f64; 3],
u: [f64; 3],
v: [f64; 3],
) -> (f64, f64, bool, f64, f64) {
use std::f64::consts::PI;
if poly.len() < 2 {
return (0.0, TAU, true, 0.0, PI);
}
let mut thetas: Vec<f64> = Vec::new();
let (mut phi_min, mut phi_max) = (f64::MAX, f64::MIN);
for &p in poly {
let d = norm3([p[0] - center[0], p[1] - center[1], p[2] - center[2]]);
let cphi = dot3(d, pole).clamp(-1.0, 1.0);
let phi = cphi.acos();
phi_min = phi_min.min(phi);
phi_max = phi_max.max(phi);
thetas.push(dot3(d, v).atan2(dot3(d, u)));
}
let (theta_min, theta_span, full) = angular_span(&thetas);
// Meridians converge at the poles, so pad the colatitude a touch toward each
// pole the face reaches so the lines meet the rim rather than stopping short.
(theta_min, theta_span, full, (phi_min - 0.05).max(0.0), (phi_max + 0.05).min(PI))
}
/// Revolution-angle span of a torus face from its boundary polygon.
fn torus_phi_range(poly: &[[f64; 3]], center: [f64; 3], u: [f64; 3], v: [f64; 3]) -> (f64, f64, bool) {
if poly.len() < 2 {
return (0.0, TAU, true);
}
let phis: Vec<f64> = poly
.iter()
.map(|&p| {
let rel = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
dot3(rel, v).atan2(dot3(rel, u))
})
.collect();
angular_span(&phis)
}
/// Reduce a set of angles to a `(min, span, full)` arc. Mirrors `angular_range`'s
/// gap detection: the largest gap between sorted angles is the arc's *outside*,
/// so the arc runs from the gap's end round to its start; a small largest gap
/// means the angles wrap the whole circle.
fn angular_span(angles: &[f64]) -> (f64, f64, bool) {
if angles.is_empty() {
return (0.0, TAU, true);
}
let mut a: Vec<f64> = angles.iter().map(|x| x.rem_euclid(TAU)).collect();
a.sort_by(|x, y| x.partial_cmp(y).unwrap());
let mut gap_max = 0.0;
let mut gap_at = 0usize;
for i in 0..a.len() {
let next = if i + 1 < a.len() { a[i + 1] } else { a[0] + TAU };
let gap = next - a[i];
if gap > gap_max {
gap_max = gap;
gap_at = i;
}
}
if gap_max < TAU / 12.0 {
return (0.0, TAU, true); // wraps the full circle
}
let start = a[(gap_at + 1) % a.len()];
(start, TAU - gap_max, false)
}
/// World-space silhouette generators for each cone/cylinder face — the params a
/// per-frame DISPSILH pass needs. `xform` is the solid's body transform (or
/// `None`); directions are rotated by it, the base point is placed by it and
@@ -314,42 +476,107 @@ fn collect_curved_gens(
};
[w[0] as f32, w[1] as f32, w[2] as f32]
};
let scale = xform.map(|(_, _, s)| s).unwrap_or(1.0);
// Place a world point and split it into the double-single (high, low) pair.
let place = |p: [f64; 3]| -> ([f32; 3], [f32; 3]) {
let (wx, wy, wz) = match xform {
Some((m, tr, s)) => (
s * (p[0] * m[0] + p[1] * m[3] + p[2] * m[6]) + tr[0],
s * (p[0] * m[1] + p[1] * m[4] + p[2] * m[7]) + tr[1],
s * (p[0] * m[2] + p[1] * m[5] + p[2] * m[8]) + tr[2],
),
None => (p[0], p[1], p[2]),
};
let (hx, hy, hz) = (wx as f32, wy as f32, wz as f32);
(
[hx, hy, hz],
[(wx - hx as f64) as f32, (wy - hy as f64) as f32, (wz - hz as f64) as f32],
)
};
let mut out = Vec::new();
for face in sat.faces() {
let Some(g) = cone_face_geom(sat, &face) else {
let Some(surf) = sat.resolve(face.surface()) else {
continue;
};
// Base circle centre in world space (double-single) — heights are along
// the unit axis, so the base sits at h_min.
let base_local = [
g.center[0] + g.h_min * g.axis[0],
g.center[1] + g.h_min * g.axis[1],
g.center[2] + g.h_min * g.axis[2],
];
let (bx, by, bz, scale) = match xform {
Some((m, tr, s)) => (
s * (base_local[0] * m[0] + base_local[1] * m[3] + base_local[2] * m[6]) + tr[0],
s * (base_local[0] * m[1] + base_local[1] * m[4] + base_local[2] * m[7]) + tr[1],
s * (base_local[0] * m[2] + base_local[1] * m[5] + base_local[2] * m[8]) + tr[2],
s,
),
None => (base_local[0], base_local[1], base_local[2], 1.0),
};
let (hx, hy, hz) = (bx as f32, by as f32, bz as f32);
out.push(CurvedGen {
base: [hx, hy, hz],
base_low: [(bx - hx as f64) as f32, (by - hy as f64) as f32, (bz - hz as f64) as f32],
axis: rot_dir(g.axis),
u_dir: rot_dir(g.u_dir),
v_dir: rot_dir(g.v_dir),
radius: (g.radius * scale) as f32,
tan_a: g.tan_a as f32,
h_min: 0.0,
h_max: ((g.h_max - g.h_min) * scale) as f32,
theta_min: g.theta_min as f32,
theta_span: g.theta_span as f32,
full: g.full,
});
match surf.entity_type.as_str() {
"cone-surface" => {
let Some(g) = cone_face_geom(sat, &face) else { continue };
let base_local = [
g.center[0] + g.h_min * g.axis[0],
g.center[1] + g.h_min * g.axis[1],
g.center[2] + g.h_min * g.axis[2],
];
let (base, base_low) = place(base_local);
out.push(CurvedGen::Cone {
base,
base_low,
axis: rot_dir(g.axis),
u_dir: rot_dir(g.u_dir),
v_dir: rot_dir(g.v_dir),
radius: (g.radius * scale) as f32,
tan_a: g.tan_a as f32,
h_max: ((g.h_max - g.h_min) * scale) as f32,
theta_min: g.theta_min as f32,
theta_span: g.theta_span as f32,
full: g.full,
});
}
"sphere-surface" => {
let Some(sphere) = SatSphereSurface::from_record(surf) else { continue };
let (cx, cy, cz) = sphere.center();
let pole = norm3([sphere.pole().0, sphere.pole().1, sphere.pole().2]);
let u = norm3([
sphere.u_direction().0,
sphere.u_direction().1,
sphere.u_direction().2,
]);
let v = cross3(pole, u);
let poly = collect_face_polygon(sat, &face, 48);
let (tmin, tspan, full, pmin, pmax) =
sphere_param_range(&poly, [cx, cy, cz], pole, u, v);
let (center, center_low) = place([cx, cy, cz]);
out.push(CurvedGen::Sphere {
center,
center_low,
pole: rot_dir(pole),
u_dir: rot_dir(u),
v_dir: rot_dir(v),
radius: (sphere.radius() * scale) as f32,
theta_min: tmin as f32,
theta_span: tspan as f32,
full,
phi_min: pmin as f32,
phi_max: pmax as f32,
});
}
"torus-surface" => {
let Some(torus) = SatTorusSurface::from_record(surf) else { continue };
let (cx, cy, cz) = torus.center();
let axis = norm3([torus.normal().0, torus.normal().1, torus.normal().2]);
let u = norm3([
torus.u_direction().0,
torus.u_direction().1,
torus.u_direction().2,
]);
let v = cross3(axis, u);
let poly = collect_face_polygon(sat, &face, 48);
let (pmin, pspan, full) = torus_phi_range(&poly, [cx, cy, cz], u, v);
let (center, center_low) = place([cx, cy, cz]);
out.push(CurvedGen::Torus {
center,
center_low,
axis: rot_dir(axis),
u_dir: rot_dir(u),
v_dir: rot_dir(v),
major: (torus.major_radius() * scale) as f32,
minor: (torus.minor_radius() * scale) as f32,
phi_min: pmin as f32,
phi_span: pspan as f32,
full,
});
}
_ => {}
}
}
out
}
+52 -24
View File
@@ -39,31 +39,59 @@ pub struct MeshModel {
/// A curved face's generator, kept so a view-dependent silhouette (DISPSILH)
/// can be computed per frame — the silhouette is where the surface turns away
/// from the eye, which no baked edge can capture. World-space, post body
/// transform; the base point carries a double-single low half so it stays
/// precise at UTM scale like the mesh verts.
/// transform; base/centre points carry a double-single low half so they stay
/// precise at UTM scale like the mesh verts. Each variant also carries the
/// face's parametric extent so the silhouette is clipped to the actual face
/// rather than drawn across the whole (possibly partial) surface.
#[derive(Clone, Copy, Debug)]
pub struct CurvedGen {
/// Base-circle centre (world), high half of the double-single.
pub base: [f32; 3],
/// Low residual paired with `base`.
pub base_low: [f32; 3],
/// Unit axis direction (world).
pub axis: [f32; 3],
/// Radial frame: `u` is the θ=0 direction, `v = axis × u`.
pub u_dir: [f32; 3],
pub v_dir: [f32; 3],
/// Radius at the base (`h = 0`).
pub radius: f32,
/// `tan(half-angle)`: radius at height `h` is `radius + h * tan_a`
/// (0 for a cylinder, non-zero for a cone).
pub tan_a: f32,
/// Height span along the axis the face covers.
pub h_min: f32,
pub h_max: f32,
/// Angular span the face covers; `full` = a closed revolution.
pub theta_min: f32,
pub theta_span: f32,
pub full: bool,
pub enum CurvedGen {
/// Cone / cylinder: two edge-on lines up the surface.
Cone {
base: [f32; 3],
base_low: [f32; 3],
axis: [f32; 3],
/// Radial frame: `u` is the θ=0 direction, `v = axis × u`.
u_dir: [f32; 3],
v_dir: [f32; 3],
/// Radius at the base (`h = 0`).
radius: f32,
/// `tan(half-angle)`: radius at height `h` is `radius + h * tan_a`.
tan_a: f32,
/// Height span along the axis the face covers (base is `h = 0`).
h_max: f32,
theta_min: f32,
theta_span: f32,
full: bool,
},
/// Sphere: the great circle perpendicular to the view, clipped to the
/// face's longitude/colatitude window.
Sphere {
center: [f32; 3],
center_low: [f32; 3],
pole: [f32; 3],
u_dir: [f32; 3],
v_dir: [f32; 3],
radius: f32,
theta_min: f32,
theta_span: f32,
full: bool,
phi_min: f32,
phi_max: f32,
},
/// Torus: the two extreme circles of the tube (outer/inner), clipped to the
/// revolution window the face covers.
Torus {
center: [f32; 3],
center_low: [f32; 3],
axis: [f32; 3],
u_dir: [f32; 3],
v_dir: [f32; 3],
major: f32,
minor: f32,
phi_min: f32,
phi_span: f32,
full: bool,
},
}
#[derive(Clone, Debug)]
+126 -56
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);
}
}
}
}
}
}
+7 -2
View File
@@ -62,6 +62,10 @@ pub struct ViewportData {
pub(in crate::scene) images: Arc<Vec<ImageModel>>,
pub(in crate::scene) meshes: Arc<Vec<MeshLodSet>>,
pub(in crate::scene) uniforms: Uniforms,
/// World-space camera forward — the parallel view direction. DISPSILH
/// silhouettes use this alone (not the eye position), so the outline follows
/// the view angle and doesn't shift with pan / perspective foreshortening.
pub(in crate::scene) view_dir: glam::Vec3,
/// Camera rotation matrix derived from the quaternion.
/// Used by the ViewCube pipeline — no gimbal lock.
pub(in crate::scene) cam_rotation: Mat4,
@@ -562,9 +566,9 @@ impl shader::Primitive for Primitive {
// default, so this is a no-op unless the drawing enables it). Only
// in modes that draw edges — pure shaded hides them.
if vp.display_silhouette && (vp.view_wireframe || vp.show_3d_edges) {
inner.upload_silhouettes(device, &vp.meshes[..], eye);
inner.upload_silhouettes(device, &vp.meshes[..], vp.view_dir);
} else {
inner.upload_silhouettes(device, &[], eye);
inner.upload_silhouettes(device, &[], vp.view_dir);
}
inner.compute_wire_scissors(view_rot, eye, clip_size.width, clip_size.height);
inner.compute_wipeout_scissors(view_rot, eye, clip_size.width, clip_size.height);
@@ -1361,6 +1365,7 @@ impl Scene {
images,
meshes,
uniforms,
view_dir: (inst.camera.rotation * glam::Vec3::NEG_Z).normalize_or(glam::Vec3::NEG_Z),
cam_rotation: inst.camera.view_rotation_mat() * self.viewcube_ucs_mat(),
compass_rotation: inst.camera.view_rotation_mat(),
// Only the active viewport gets the hovered-region highlight.