From 8c72a06790dd5ca1e20858593ed9bc92c67d03ec Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 17 Jul 2026 23:30:05 +0300 Subject: [PATCH] feat(solid3d): ISOLINES and silhouettes on sphere and torus faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scene/convert/solid3d_tess.rs | 319 +++++++++++++++++++++++++----- src/scene/model/mesh_model.rs | 76 ++++--- src/scene/pipeline/mod.rs | 182 +++++++++++------ src/scene/view/render.rs | 9 +- 4 files changed, 458 insertions(+), 128 deletions(-) diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 4ebbe1169..acd3c3a74 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -267,33 +267,195 @@ fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option { }) } +/// 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 { + (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 = 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 = 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 = 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 } diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index add6f2242..73b95a0e8 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -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)] diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 71be8808b..b919273f0 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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 = 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 = 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; 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); + } + } + } } } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 3207ace39..e0efb973d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -62,6 +62,10 @@ pub struct ViewportData { pub(in crate::scene) images: Arc>, pub(in crate::scene) meshes: Arc>, 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.