forked from 1b-agent/opencadstudio-upstream
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:
1 parent
ae5231427b
commit
8c72a06790
4 files changed
+458
-128
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user