refactor(render): tessellate curved entities from their own curve

The point lists were built three different ways and none of them scaled
with the geometry. A full ellipse was cut into sixteen segments per half
and an elliptical arc into thirty-two, whatever its size, so anything
large read as a polygon; a circle worked out its own segment count from
the chord tolerance; an arc was handed to truck's arc-through-three-
points builder, which cancels at survey coordinates.

All three now sample the entity's curve at the render pass's chord
tolerance, so how round something looks follows how big it is and how
far in the view is zoomed. The ellipse also stops casting its local
coordinates to f32 on the way in — the vertex path splits f64 into a
double-single pair precisely so that precision survives to the shader,
and throwing it away first defeated that.

What does not change is the kind of object each entity hands over.
EXTRUDE, REVOLVE and SWEEP read their profile and path out of Contour or
Curve and have no arm for a bare point list, so an arc or an ellipse
given as Lines would quietly stop being usable as either. The arc keeps
its circle_arc edge for that reason; only the ellipse's control points
change, and they keep the same two-half-edge wire.

The ellipse now decides it is closed from its own endpoints rather than
from its stored parameters, so a file that writes an end a hair short of
a full turn still produces a wire a plane can attach to.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-09 00:55:29 +03:00
1 parent c2cc6fd7c4
commit 90abfc21a9
5 files changed
+116 -138

No files matched your search

Generated
+1 -1
View File
@@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git#39083307452ba14b3968bdb0738e2665d3d18e62"
source = "git+https://github.com/HakanSeven12/cadkernel.git#9406868b1bedd57d121b11fc75658bf9eaa2c764"
dependencies = [
"cavalier_contours",
]
+8 -5
View File
@@ -26,9 +26,6 @@ fn to_truck(arc: &Arc) -> TruckEntity {
// Compute OCS basis vectors for this entity's normal.
let (ax, ay) = crate::scene::view::transform::ocs_axes(normal);
let ccw_end = if ea >= sa { ea } else { ea + TAU };
let mid_a = sa + (ccw_end - sa) * 0.5;
// Arc centre in WCS.
let (cwx, cwy, cwz) = crate::scene::view::transform::ocs_point_to_wcs((cx, cy, cz), normal);
@@ -45,7 +42,8 @@ fn to_truck(arc: &Arc) -> TruckEntity {
// Centre, ends, arc-length midpoint and the quadrants the sweep actually
// covers, all from the entity's own curve. Circles and ellipses (closed
// curves) deliberately emit no midpoint; see #34.
let snap = crate::entities::curve::snap_from(&crate::entities::curve::arc_curve(arc));
let curve = crate::entities::curve::arc_curve(arc);
let snap = crate::entities::curve::snap_from(&curve);
let tangent = TangentGeom::Circle {
center: [cwx as f32, cwy as f32, cwz as f32],
radius: r as f32,
@@ -87,9 +85,14 @@ fn to_truck(arc: &Arc) -> TruckEntity {
};
}
// Kept as a truck edge rather than a point list: EXTRUDE, REVOLVE and
// SWEEP take their profile and path from `Curve` / `Contour` and have no
// arm for `Lines`, so handing them one would quietly stop an arc being
// usable as either.
let p_start = arc_pt(sa);
let p_end = arc_pt(ea);
let p_mid = arc_pt(mid_a);
let ccw_end = if ea >= sa { ea } else { ea + TAU };
let p_mid = arc_pt(sa + (ccw_end - sa) * 0.5);
let v_start = builder::vertex(p_start);
let v_end = builder::vertex(p_end);
let edge = builder::circle_arc(&v_start, &v_end, p_mid);
+7 -25
View File
@@ -23,8 +23,8 @@ fn to_truck(circle: &Circle) -> TruckEntity {
let rf = r as f32;
// Centre and quadrants come from the entity's own curve, so the same
// definition answers here, in the tessellation and in a trim.
let snap = crate::entities::curve::snap_from(&crate::entities::curve::circle_curve(circle));
let snap_pts = snap.snap_pts;
let curve = crate::entities::curve::circle_curve(circle);
let snap_pts = crate::entities::curve::snap_from(&curve).snap_pts;
let tangent = TangentGeom::Circle {
center: [cwx as f32, cwy as f32, cwz as f32],
radius: rf,
@@ -74,34 +74,16 @@ fn to_truck(circle: &Circle) -> TruckEntity {
};
}
// Tessellate directly as a cos/sin polyline rather than a truck
// Sampled from the entity's own curve rather than as a truck
// `circle_arc` (arc-through-three-points). At large WCS coordinates
// (e.g. −1.2M UTM) the three-point fit cancels catastrophically — the
// circle comes back with a ~3% radius wobble and uneven segment lengths,
// which then throws off a dashed linetype's dash spacing. Direct
// evaluation only adds a small ±r term to the centre, so it stays precise;
// the `Lines` path RTE-splits the absolute-f64 points into the
// evaluation only adds a small ±r term to the centre, so it stays
// precise; the `Lines` path RTE-splits the absolute-f64 points into the
// double-single the shader reconstructs.
let tol = crate::scene::convert::truck_tess::current_curve_tol();
// Chord-height tolerance → segment count: sag = r·(1 − cos(π/N)).
let n = if r > tol {
(std::f64::consts::PI / (1.0 - tol / r).clamp(-1.0, 1.0).acos())
.ceil()
.clamp(16.0, 4096.0) as usize
} else {
16
};
let tau = std::f64::consts::TAU;
let mut pts: Vec<[f64; 3]> = Vec::with_capacity(n + 1);
for i in 0..=n {
let a = i as f64 * tau / n as f64;
let (s, c) = a.sin_cos();
pts.push([
cwx + r * (c * ax.0 + s * ay.0),
cwy + r * (c * ax.1 + s * ay.1),
cwz + r * (c * ax.2 + s * ay.2),
]);
}
let pts = crate::entities::curve::curve_points(&curve);
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
+16
View File
@@ -331,6 +331,22 @@ pub fn entity_curve_xy(entity: &EntityType) -> Option<Curve> {
})
}
/// World-space wire points for a curve, cut to the render pass's current
/// chord tolerance.
///
/// The density comes from how far a chord may sag from the curve, not from a
/// segment count: an arc a metre across and one a kilometre across need
/// different numbers of points to look equally round, and the tolerance the
/// render pass carries is already zoom-adaptive.
///
/// Everything stays `f64` to the end. The narrowing belongs at the GPU
/// boundary, where the residual is kept as the low half of a double-single
/// pair; casting local coordinates on the way in throws away precision the
/// shader was built to reconstruct.
pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> {
curve.tessellate_within(crate::scene::convert::truck_tess::current_curve_tol())
}
/// The snap candidates an entity's curve offers, in the two channels the
/// wire model carries them in.
#[derive(Debug, Default, Clone)]
+84 -107
View File
@@ -1,7 +1,9 @@
use acadrust::entities::Ellipse;
use crate::t;
use truck_modeling::{builder, BSplineCurve, Curve, Edge, KnotVec, Point3, Wire};
use crate::entities::curve::CurveSnap;
use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
@@ -10,118 +12,93 @@ use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
const TAU: f64 = std::f64::consts::TAU;
fn to_truck(ell: &Ellipse) -> TruckEntity {
let normal = (ell.normal.x, ell.normal.y, ell.normal.z);
let (nx, ny, nz) = normal;
// ELLIPSE is one of the few WCS entities in DXF: `center` (code 10) and
// `major_axis` (code 11) are world coordinates already — unlike ARC /
// CIRCLE, whose centers are OCS. (This used to run both through the
// arbitrary-axis OCS, which misplaced any ellipse whose normal isn't
// Z-up — e.g. the (0,0,-1) result of a mirrored-block explode.)
let (cwx, cwy, cwz) = (ell.center.x, ell.center.y, ell.center.z);
let wcs_maj = glam::Vec3::new(
ell.major_axis.x as f32,
ell.major_axis.y as f32,
ell.major_axis.z as f32,
);
let r_major = wcs_maj.length() as f64;
let r_minor = r_major * ell.minor_axis_ratio;
let t0 = ell.start_parameter;
let mut t1 = ell.end_parameter;
if t1 <= t0 {
t1 += TAU;
}
let u = if r_major > 1e-9 {
wcs_maj / wcs_maj.length()
} else {
glam::Vec3::X
};
// Minor axis direction: WCS_normal × u (both unit vectors, always perpendicular).
let wcs_normal = glam::Vec3::new(nx as f32, ny as f32, nz as f32);
let v_axis = wcs_normal.cross(u);
let is_closed = (t1 - t0 - TAU).abs() < 1e-6;
// Centre, axis ends and — for an open arc — its own two ends and middle,
// all read off the entity's curve rather than rebuilt from these locals.
let snap = crate::entities::curve::ellipse_curve(ell)
.map(|curve| crate::entities::curve::snap_from(&curve))
// CIRCLE, whose centers are OCS. The converter knows that; this used to
// run both through the arbitrary-axis OCS, which misplaced any ellipse
// whose normal isn't Z-up — e.g. the (0,0,-1) result of a mirrored-block
// explode.
let curve = crate::entities::curve::ellipse_curve(ell);
let snap = curve
.as_ref()
.map(crate::entities::curve::snap_from)
.unwrap_or_default();
if is_closed {
let n = 16usize;
let pts_upper: Vec<Point3> = (0..=n)
.map(|i| {
let t = (i as f64 / n as f64) * std::f64::consts::PI;
let lx = (r_major * t.cos()) as f32;
let lz = (r_minor * t.sin()) as f32;
Point3::new(
cwx + (lx * u.x + lz * v_axis.x) as f64,
cwy + (lx * u.y + lz * v_axis.y) as f64,
cwz + (lx * u.z + lz * v_axis.z) as f64,
)
})
.collect();
let pts_lower: Vec<Point3> = (0..=n)
.map(|i| {
let t = std::f64::consts::PI + (i as f64 / n as f64) * std::f64::consts::PI;
let lx = (r_major * t.cos()) as f32;
let lz = (r_minor * t.sin()) as f32;
Point3::new(
cwx + (lx * u.x + lz * v_axis.x) as f64,
cwy + (lx * u.y + lz * v_axis.y) as f64,
cwz + (lx * u.z + lz * v_axis.z) as f64,
)
})
.collect();
let v_pos = builder::vertex(*pts_upper.first().unwrap());
let v_neg = builder::vertex(*pts_upper.last().unwrap());
let kv_u = KnotVec::uniform_knot(1, n);
let kv_l = KnotVec::uniform_knot(1, n);
let spl_u = BSplineCurve::new(kv_u, pts_upper);
let spl_l = BSplineCurve::new(kv_l, pts_lower);
let edge_upper = Edge::new(&v_pos, &v_neg, Curve::BSplineCurve(spl_u));
let edge_lower = Edge::new(&v_neg, &v_pos, Curve::BSplineCurve(spl_l));
let wire: Wire = [edge_upper, edge_lower].into_iter().collect();
let snap_pts = snap.snap_pts.clone();
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Contour(wire),
snap_pts,
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
}
} else {
let n = 32usize;
let ctrl_pts: Vec<Point3> = (0..=n)
.map(|i| {
let t = t0 + (t1 - t0) * (i as f64 / n as f64);
let lx = (r_major * t.cos()) as f32;
let lz = (r_minor * t.sin()) as f32;
Point3::new(
cwx + (lx * u.x + lz * v_axis.x) as f64,
cwy + (lx * u.y + lz * v_axis.y) as f64,
cwz + (lx * u.z + lz * v_axis.z) as f64,
)
})
.collect();
let kv = KnotVec::uniform_knot(1, n);
let bspline = BSplineCurve::new(kv, ctrl_pts.clone());
let v_start = builder::vertex(*ctrl_pts.first().unwrap());
let v_end = builder::vertex(*ctrl_pts.last().unwrap());
let edge = Edge::new(&v_start, &v_end, Curve::BSplineCurve(bspline));
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Curve(edge),
snap_pts: snap.snap_pts.clone(),
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
}
// The points come from the entity's own curve, cut to the render pass's
// chord tolerance. The two fixed counts this replaced — sixteen per half
// for a full ellipse, thirty-two for an arc — faceted visibly on
// anything large, and the local coordinates were built in `f32`, which
// threw away precision the double-single vertex path exists to keep.
//
// What does not change is the shape of the object. EXTRUDE, REVOLVE and
// SWEEP read their profile out of `Contour` / `Curve` and have no arm for
// a bare point list, so an ellipse handed over as `Lines` would stop
// being usable as either.
let empty = |snap: CurveSnap| TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(Vec::new()),
snap_pts: snap.snap_pts,
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
};
let Some(planar) = curve else {
return empty(snap);
};
let points: Vec<Point3> = crate::entities::curve::curve_points(&planar)
.into_iter()
.map(|p| Point3::new(p[0], p[1], p[2]))
.collect();
if points.len() < 2 {
return empty(snap);
}
// Closure decided on the points rather than on the stored parameters. A
// file that writes an end parameter a hair short of a full turn still
// draws a closed ellipse, and an open edge whose two vertices sit on top
// of each other is not something a plane can be attached to.
let ends_meet = {
let (first, last) = (points[0], *points.last().expect("checked non-empty"));
let span = first.x.abs().max(first.y.abs()).max(first.z.abs()).max(1.0);
let gap = first - last;
(gap.x * gap.x + gap.y * gap.y + gap.z * gap.z).sqrt() <= 1e-9 * span
};
let object = if planar.is_closed() || ends_meet {
// Two half-edges rather than one closed edge: a single edge whose
// two vertices coincide is not a wire truck will attach a plane to.
let split = points.len() / 2;
let upper = points[..=split].to_vec();
let lower = points[split..].to_vec();
let v_pos = builder::vertex(points[0]);
let v_neg = builder::vertex(points[split]);
let edge_upper = Edge::new(&v_pos, &v_neg, polyline_curve(upper));
let edge_lower = Edge::new(&v_neg, &v_pos, polyline_curve(lower));
TruckObject::Contour([edge_upper, edge_lower].into_iter().collect::<Wire>())
} else {
let v_start = builder::vertex(points[0]);
let v_end = builder::vertex(*points.last().expect("checked non-empty"));
TruckObject::Curve(Edge::new(&v_start, &v_end, polyline_curve(points)))
};
TruckEntity {
pick_tris: Vec::new(),
object,
snap_pts: snap.snap_pts,
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
}
}
/// A truck curve that passes through every one of `points` in order.
///
/// Degree one, so the control polygon *is* the curve — the same thing the
/// hand-built sampling produced, without restating how a B-spline is put
/// together at each site.
fn polyline_curve(points: Vec<Point3>) -> Curve {
let spans = points.len().saturating_sub(1);
Curve::BSplineCurve(BSplineCurve::new(KnotVec::uniform_knot(1, spans), points))
}
fn grips(ell: &Ellipse) -> Vec<GripDef> {