feat(tess): use angular sampling
Route curve, hatch, sweep, loft, surface, edge, and isoline tessellation through the shared kernel policy.
This commit is contained in:
parent
f607422c9d
commit
96be8214f9
21 changed files with 158 additions and 767 deletions
56
Cargo.lock
generated
56
Cargo.lock
generated
|
|
@ -6,7 +6,7 @@ version = 4
|
|||
name = "OpenCADStudio"
|
||||
version = "0.9.4"
|
||||
dependencies = [
|
||||
"acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae)",
|
||||
"acadifc",
|
||||
"ashpd",
|
||||
"bincode",
|
||||
"bytemuck",
|
||||
|
|
@ -70,25 +70,11 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
|||
[[package]]
|
||||
name = "acadifc"
|
||||
version = "0.5.0"
|
||||
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae#6059baed781c6399d8259aa85c19be803ba95205"
|
||||
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c15cbf0#c15cbf0ac5c61202f1d9b1a5d344a03bb1e65871"
|
||||
dependencies = [
|
||||
"acadrust 0.4.1 (git+https://github.com/HakanSeven12/cadcodec.git?rev=36e841f)",
|
||||
"acadrust",
|
||||
"base64",
|
||||
"cadkernel 0.1.0 (git+https://github.com/HakanSeven12/cadkernel.git?rev=6f34deb)",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "acadifc"
|
||||
version = "0.5.0"
|
||||
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396#c65d396abc3defcf96b1d71a3f812c8ad993e77c"
|
||||
dependencies = [
|
||||
"acadrust 0.4.1 (git+https://github.com/HakanSeven12/cadcodec.git?rev=d645c7f)",
|
||||
"base64",
|
||||
"cadkernel 0.1.0 (git+https://github.com/HakanSeven12/cadkernel.git)",
|
||||
"cadkernel",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
|
@ -118,29 +104,6 @@ dependencies = [
|
|||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.4.1"
|
||||
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=d645c7f#d645c7f526c199c02fcab7272d6b6f2d1c6b91ae"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"anyhow",
|
||||
"bitflags 2.13.1",
|
||||
"byteorder",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memmap2",
|
||||
"nom 7.1.3",
|
||||
"once_cell",
|
||||
"rayon",
|
||||
"ryu",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
|
|
@ -927,16 +890,11 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|||
[[package]]
|
||||
name = "cadkernel"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6f34deb#6f34deb57eedf0dc5908bf225917f19c9707682d"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=691c477#691c47756f30bb32fad2342afeec14d5985783ac"
|
||||
dependencies = [
|
||||
"cavalier_contours",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadkernel"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git#860b9df7e5fc4a495334a6329560a4cde33659db"
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.13.0"
|
||||
|
|
@ -3788,7 +3746,7 @@ dependencies = [
|
|||
name = "ocs_plugin_api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae)",
|
||||
"acadifc",
|
||||
"bincode",
|
||||
"getrandom 0.2.17",
|
||||
"interprocess",
|
||||
|
|
@ -3803,7 +3761,7 @@ dependencies = [
|
|||
name = "ocs_web_worker"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396)",
|
||||
"acadifc",
|
||||
"bincode",
|
||||
"console_error_panic_hook",
|
||||
"getrandom 0.3.4",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ env_logger = "0.11"
|
|||
# The CAD stack is reached through acadifc, which re-exports the codec and
|
||||
# the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…`
|
||||
# paths keep resolving.
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "6059bae", features = ["serde", "offset"] }
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c15cbf0", features = ["serde", "offset"] }
|
||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||
flate2 = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ license = "GPL-3.0-only"
|
|||
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
|
||||
# `HostApi` runtime surface. The default crate stays dependency-free so engine
|
||||
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "6059bae", optional = true, features = ["serde"] }
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c15cbf0", optional = true, features = ["serde"] }
|
||||
|
||||
# Runtime IPC and serialization (host feature only).
|
||||
interprocess = { version = "2", optional = true }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ publish = false
|
|||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", features = ["serde"] }
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c15cbf0", features = ["serde"] }
|
||||
bincode = "1.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
|
|
|
|||
|
|
@ -670,14 +670,7 @@ pub(crate) fn thick_band_tube(
|
|||
(fill_tris, lines)
|
||||
}
|
||||
|
||||
/// Build a continuous WCS point list + a per-point FULL band width for a
|
||||
/// tapered wide polyline, so the wire shader can interpolate each segment's two
|
||||
/// endpoint widths. Each `verts` entry is `(location_xy, bulge_to_next,
|
||||
/// start_width, end_width)` — the effective full widths at that vertex's segment
|
||||
/// start and end (already resolved against the polyline's constant width). Arcs
|
||||
/// are sampled in 16 steps with the width interpolated linearly along the arc.
|
||||
/// A shared vertex is emitted once (carrying the previous segment's end width),
|
||||
/// which is exact for the usual continuous taper.
|
||||
/// Build a continuous WCS point list and full width for a tapered polyline.
|
||||
pub(crate) fn tapered_band_points(
|
||||
verts: &[([f64; 2], f64, f64, f64)],
|
||||
is_closed: bool,
|
||||
|
|
@ -701,9 +694,10 @@ pub(crate) fn tapered_band_points(
|
|||
if bulge.abs() < 1e-9 {
|
||||
push(p1[0], p1[1], ew0 as f32);
|
||||
} else if let Some(arc) = BulgeArc::from_bulge(p0, p1, bulge) {
|
||||
for j in 1..=16usize {
|
||||
let t = j as f64 / 16.0;
|
||||
let s = arc.sample(t);
|
||||
let samples = arc.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE);
|
||||
let segments = samples.len().saturating_sub(1).max(1);
|
||||
for (index, s) in samples.into_iter().enumerate().skip(1) {
|
||||
let t = index as f64 / segments as f64;
|
||||
push(s[0], s[1], (sw0 + (ew0 - sw0) * t) as f32);
|
||||
}
|
||||
}
|
||||
|
|
@ -792,4 +786,3 @@ pub(crate) fn polyline_segment_fill(
|
|||
Some(boundary)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -331,53 +331,9 @@ 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.
|
||||
/// World-space wire points sampled by the kernel's angular policy.
|
||||
pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> {
|
||||
let tolerance = crate::scene::convert::curve_tol::curve_tol_for(curve_size(&curve.curve));
|
||||
curve.tessellate_within(tolerance)
|
||||
}
|
||||
|
||||
/// How big a curve is, for deciding how finely to sample it.
|
||||
///
|
||||
/// A radius where there is one, since that is what the chord-height relation
|
||||
/// is written in terms of. `0` for the kinds whose sampling density is not a
|
||||
/// question of curvature — a straight run needs its two ends and nothing in
|
||||
/// between — which leaves those on the frame's own tolerance.
|
||||
fn curve_size(curve: &Curve) -> f64 {
|
||||
match curve {
|
||||
Curve::Circle(circle) => circle.radius,
|
||||
Curve::Arc(arc) => arc.radius,
|
||||
Curve::Ellipse(arc) => arc.ellipse.major_radius,
|
||||
// A polyline's bulges and a spline's bends are the same question asked
|
||||
// of a chain, and its own extent is the nearest thing to a radius.
|
||||
Curve::Polyline(_) | Curve::Nurbs(_) => {
|
||||
let sampled = curve.tessellate(64.0);
|
||||
let (mut low, mut high) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
for point in &sampled {
|
||||
for axis in 0..2 {
|
||||
low[axis] = low[axis].min(point[axis]);
|
||||
high[axis] = high[axis].max(point[axis]);
|
||||
}
|
||||
}
|
||||
if low[0] > high[0] {
|
||||
0.0
|
||||
} else {
|
||||
(high[0] - low[0]).max(high[1] - low[1]) * 0.5
|
||||
}
|
||||
}
|
||||
Curve::Line(_) | Curve::Ray(_) | Curve::XLine(_) => 0.0,
|
||||
}
|
||||
curve.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE)
|
||||
}
|
||||
|
||||
/// The snap candidates an entity's curve offers, in the two channels the
|
||||
|
|
|
|||
|
|
@ -24,11 +24,7 @@ fn to_render(ell: &Ellipse) -> RenderEntity {
|
|||
.map(crate::entities::curve::snap_from)
|
||||
.unwrap_or_default();
|
||||
|
||||
// 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.
|
||||
// The points come from the entity's own kernel curve and angular policy.
|
||||
//
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use crate::command::EntityTransform;
|
|||
use crate::entities::common::{center_grip, circle_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro};
|
||||
use crate::entities::traits::{FallbackTess, Grippable, PropertyEditable, Transformable};
|
||||
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
|
||||
use crate::scene::convert::tess_util::{arc_segments, arc_signed_span, wire_chord_tol, FallbackGeometry};
|
||||
use crate::scene::convert::tess_util::FallbackGeometry;
|
||||
use crate::scene::model::wire_model::SnapHint;
|
||||
|
||||
/// The area the hatch's boundary paths enclose.
|
||||
|
|
@ -49,19 +49,20 @@ fn boundary_area(h: &Hatch) -> f64 {
|
|||
}
|
||||
|
||||
/// A hatch boundary edge as a kernel curve, in the hatch's own OCS.
|
||||
fn edge_curve(edge: &BoundaryEdge) -> Option<KernelCurve> {
|
||||
pub(crate) fn edge_curve(edge: &BoundaryEdge) -> Option<KernelCurve> {
|
||||
Some(match edge {
|
||||
BoundaryEdge::Line(l) => KernelCurve::Line(KernelLine {
|
||||
start: [l.start.x, l.start.y],
|
||||
end: [l.end.x, l.end.y],
|
||||
}),
|
||||
BoundaryEdge::CircularArc(a) => {
|
||||
// A clockwise edge is the same arc walked the other way, so the
|
||||
// stored angles swap rather than the sweep going negative.
|
||||
let (start, end) = if a.counter_clockwise {
|
||||
(a.start_angle, a.end_angle)
|
||||
} else {
|
||||
(a.end_angle, a.start_angle)
|
||||
(
|
||||
std::f64::consts::TAU - a.end_angle,
|
||||
std::f64::consts::TAU - a.start_angle,
|
||||
)
|
||||
};
|
||||
KernelCurve::Arc(KernelArc {
|
||||
centre: [a.center.x, a.center.y],
|
||||
|
|
@ -79,7 +80,10 @@ fn edge_curve(edge: &BoundaryEdge) -> Option<KernelCurve> {
|
|||
let (start, end) = if e.counter_clockwise {
|
||||
(e.start_angle, e.end_angle)
|
||||
} else {
|
||||
(e.end_angle, e.start_angle)
|
||||
(
|
||||
std::f64::consts::TAU - e.end_angle,
|
||||
std::f64::consts::TAU - e.start_angle,
|
||||
)
|
||||
};
|
||||
KernelCurve::Ellipse(KernelEllipseArc {
|
||||
ellipse: KernelEllipse {
|
||||
|
|
@ -991,146 +995,41 @@ impl FallbackTess for Hatch {
|
|||
let mut snap_pts: Vec<(Vec3, SnapHint)> = Vec::new();
|
||||
for path in &self.paths {
|
||||
for edge in &path.edges {
|
||||
let Some(curve) = edge_curve(edge) else {
|
||||
continue;
|
||||
};
|
||||
let local = curve
|
||||
.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE);
|
||||
if local.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if !pts.is_empty() {
|
||||
pts.push([f64::NAN; 3]);
|
||||
}
|
||||
let world: Vec<[f64; 3]> = local
|
||||
.into_iter()
|
||||
.map(|point| to_wcs(point[0], point[1]))
|
||||
.collect();
|
||||
match edge {
|
||||
BoundaryEdge::Polyline(poly) => {
|
||||
// Hatch-boundary polyline vertices encode bulge in
|
||||
// `Vector3.z`; straight segments emit just the
|
||||
// start vertex, bulged segments tessellate the arc
|
||||
// between v0 → v1.
|
||||
let verts = &poly.vertices;
|
||||
let count = verts.len();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
// Break the wire between this polyline and whatever
|
||||
// preceded it — without the separator the renderer
|
||||
// draws a ghost segment from the previous edge / path
|
||||
// straight to this polyline's first vertex, which
|
||||
// shows up as a stray boundary line between hatch
|
||||
// regions.
|
||||
if !pts.is_empty() {
|
||||
pts.push([f64::NAN; 3]);
|
||||
}
|
||||
let start_idx = pts.len();
|
||||
let seg_count = if poly.is_closed {
|
||||
count
|
||||
} else {
|
||||
count.saturating_sub(1)
|
||||
};
|
||||
for i in 0..seg_count {
|
||||
let v0 = &verts[i];
|
||||
let v1 = &verts[(i + 1) % count];
|
||||
let bulge = v0.z;
|
||||
let arc = if bulge.abs() < 1e-9 {
|
||||
None
|
||||
} else {
|
||||
crate::entities::common::BulgeArc::from_bulge(
|
||||
[v0.x, v0.y],
|
||||
[v1.x, v1.y],
|
||||
bulge,
|
||||
)
|
||||
};
|
||||
let Some(arc) = arc else {
|
||||
let p = to_wcs(v0.x, v0.y);
|
||||
pts.push(p);
|
||||
key_verts.push(p);
|
||||
continue;
|
||||
};
|
||||
let segs = arc_segments(
|
||||
arc.radius,
|
||||
arc.sweep.abs(),
|
||||
wire_chord_tol(arc.radius),
|
||||
);
|
||||
for j in 0..segs {
|
||||
let s = arc.sample(j as f64 / segs as f64);
|
||||
let p = to_wcs(s[0], s[1]);
|
||||
pts.push(p);
|
||||
if j == 0 {
|
||||
key_verts.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Close the loop visually for closed polylines by
|
||||
// returning to the first emitted point.
|
||||
if poly.is_closed {
|
||||
if let Some(first) = pts.get(start_idx).cloned() {
|
||||
if first[0].is_finite() {
|
||||
pts.push(first);
|
||||
}
|
||||
}
|
||||
} else if let Some(last) = verts.last() {
|
||||
let p = to_wcs(last.x, last.y);
|
||||
pts.push(p);
|
||||
key_verts.push(p);
|
||||
}
|
||||
}
|
||||
BoundaryEdge::Line(ln) => {
|
||||
let p0 = to_wcs(ln.start.x, ln.start.y);
|
||||
let p1 = to_wcs(ln.end.x, ln.end.y);
|
||||
if !pts.is_empty() {
|
||||
pts.push([f64::NAN; 3]);
|
||||
}
|
||||
pts.push(p0);
|
||||
pts.push(p1);
|
||||
key_verts.push(p0);
|
||||
key_verts.push(p1);
|
||||
}
|
||||
BoundaryEdge::CircularArc(arc) => {
|
||||
let (sa, span) =
|
||||
arc_signed_span(arc.start_angle, arc.end_angle, arc.counter_clockwise);
|
||||
let segs = arc_segments(arc.radius, span.abs(), wire_chord_tol(arc.radius));
|
||||
if !pts.is_empty() {
|
||||
pts.push([f64::NAN; 3]);
|
||||
}
|
||||
for i in 0..=segs {
|
||||
let t = sa + span * (i as f64 / segs as f64);
|
||||
let p = to_wcs(
|
||||
arc.center.x + arc.radius * t.cos(),
|
||||
arc.center.y + arc.radius * t.sin(),
|
||||
);
|
||||
pts.push(p);
|
||||
if i == 0 || i == segs {
|
||||
key_verts.push(p);
|
||||
}
|
||||
}
|
||||
snap_pts.push((
|
||||
snap_at(to_wcs(arc.center.x, arc.center.y)),
|
||||
SnapHint::Center,
|
||||
));
|
||||
}
|
||||
BoundaryEdge::EllipticArc(ell) => {
|
||||
let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x
|
||||
+ ell.major_axis_endpoint.y * ell.major_axis_endpoint.y)
|
||||
.sqrt();
|
||||
let r_min = r_maj * ell.minor_axis_ratio;
|
||||
let rot = ell.major_axis_endpoint.y.atan2(ell.major_axis_endpoint.x);
|
||||
let (sa, span) =
|
||||
arc_signed_span(ell.start_angle, ell.end_angle, ell.counter_clockwise);
|
||||
let segs = arc_segments(r_maj, span.abs(), wire_chord_tol(r_maj));
|
||||
if !pts.is_empty() {
|
||||
pts.push([f64::NAN; 3]);
|
||||
}
|
||||
let (cr, sr) = (rot.cos(), rot.sin());
|
||||
for i in 0..=segs {
|
||||
let t = sa + span * (i as f64 / segs as f64);
|
||||
let lx = r_maj * t.cos();
|
||||
let ly = r_min * t.sin();
|
||||
let p = to_wcs(
|
||||
ell.center.x + lx * cr - ly * sr,
|
||||
ell.center.y + lx * sr + ly * cr,
|
||||
);
|
||||
pts.push(p);
|
||||
if i == 0 || i == segs {
|
||||
key_verts.push(p);
|
||||
}
|
||||
}
|
||||
snap_pts.push((
|
||||
snap_at(to_wcs(ell.center.x, ell.center.y)),
|
||||
SnapHint::Center,
|
||||
));
|
||||
}
|
||||
BoundaryEdge::Polyline(poly) => key_verts.extend(
|
||||
poly.vertices
|
||||
.iter()
|
||||
.map(|point| to_wcs(point.x, point.y)),
|
||||
),
|
||||
_ => key_verts.extend([world[0], *world.last().unwrap()]),
|
||||
}
|
||||
match edge {
|
||||
BoundaryEdge::CircularArc(arc) => snap_pts.push((
|
||||
snap_at(to_wcs(arc.center.x, arc.center.y)),
|
||||
SnapHint::Center,
|
||||
)),
|
||||
BoundaryEdge::EllipticArc(ellipse) => snap_pts.push((
|
||||
snap_at(to_wcs(ellipse.center.x, ellipse.center.y)),
|
||||
SnapHint::Center,
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
pts.extend(world);
|
||||
}
|
||||
}
|
||||
if pts.is_empty() {
|
||||
|
|
|
|||
|
|
@ -308,8 +308,11 @@ fn to_render(pline: &LwPolyline) -> RenderEntity {
|
|||
center: [wcx as f32, wcy as f32, wcz as f32],
|
||||
radius: arc.radius as f32,
|
||||
});
|
||||
for j in 1..=16usize {
|
||||
let s = arc.sample(j as f64 / 16.0);
|
||||
for s in arc
|
||||
.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE)
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
{
|
||||
let (wx, wy, wz) = to_wcs(s[0], s[1]);
|
||||
path.push([wx, wy, wz]);
|
||||
}
|
||||
|
|
@ -406,8 +409,11 @@ fn to_render(pline: &LwPolyline) -> RenderEntity {
|
|||
} else if let Some(arc) =
|
||||
crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge)
|
||||
{
|
||||
for j in 1..=16usize {
|
||||
let s = arc.sample(j as f64 / 16.0);
|
||||
for s in arc
|
||||
.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE)
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
{
|
||||
let (wx, wy, wz) = to_wcs(s[0], s[1]);
|
||||
pts.push([wx, wy, wz]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,8 +258,11 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> RenderEntity {
|
|||
center: [wcx as f32, wcy as f32, wcz as f32],
|
||||
radius: arc.radius as f32,
|
||||
});
|
||||
for j in 1..=16usize {
|
||||
let s = arc.sample(j as f64 / 16.0);
|
||||
for s in arc
|
||||
.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE)
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
{
|
||||
let (wx, wy, wz) = to_wcs(s[0], s[1]);
|
||||
path.push([wx, wy, wz]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,25 +157,7 @@ pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> {
|
|||
if degree == 0 || degree >= count {
|
||||
return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
}
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for point in &spl.control_points {
|
||||
min[0] = min[0].min(point.x);
|
||||
min[1] = min[1].min(point.y);
|
||||
min[2] = min[2].min(point.z);
|
||||
max[0] = max[0].max(point.x);
|
||||
max[1] = max[1].max(point.y);
|
||||
max[2] = max[2].max(point.z);
|
||||
}
|
||||
let diagonal = ((max[0] - min[0]).powi(2)
|
||||
+ (max[1] - min[1]).powi(2)
|
||||
+ (max[2] - min[2]).powi(2))
|
||||
.sqrt();
|
||||
let tolerance = crate::scene::convert::tess_util::fill_chord_tol(diagonal.max(1.0));
|
||||
|
||||
// The kernel's space curve holds the rational and the polynomial case
|
||||
// alike — weights absent means polynomial — and samples to a chord
|
||||
// tolerance, refined where the curve bends rather than evenly.
|
||||
// The kernel's space curve holds rational and polynomial curves alike.
|
||||
let controls: Vec<[f64; 3]> = spl
|
||||
.control_points
|
||||
.iter()
|
||||
|
|
@ -188,7 +170,9 @@ pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> {
|
|||
.collect()
|
||||
});
|
||||
match NurbsCurve3::new(degree, controls, spl.knots.clone(), weights) {
|
||||
Some(curve) => curve.tessellate_within(tolerance),
|
||||
Some(curve) => {
|
||||
curve.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE)
|
||||
}
|
||||
None => spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(),
|
||||
}
|
||||
}
|
||||
|
|
@ -197,7 +181,6 @@ pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> {
|
|||
/// passes through every input point; open ends use reflected phantom points so
|
||||
/// they don't kink, closed curves wrap around.
|
||||
fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[f64; 3]> {
|
||||
const STEPS: usize = 16;
|
||||
let n = pts.len();
|
||||
if n < 2 {
|
||||
return pts.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
|
|
@ -212,7 +195,7 @@ fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[
|
|||
[pts[j].x, pts[j].y, pts[j].z]
|
||||
};
|
||||
let seg_count = if closed { n } else { n - 1 };
|
||||
let mut out: Vec<[f64; 3]> = Vec::with_capacity(seg_count * STEPS + 1);
|
||||
let mut out = Vec::new();
|
||||
for seg in 0..seg_count {
|
||||
let p1 = get(seg as isize);
|
||||
let p2 = get(seg as isize + 1);
|
||||
|
|
@ -235,15 +218,7 @@ fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[
|
|||
} else {
|
||||
get(seg as isize + 2)
|
||||
};
|
||||
// Emit t in [0, 1); the final segment also emits t = 1 so the curve
|
||||
// closes onto the last point (no duplicate shared vertices otherwise).
|
||||
let last = if seg == seg_count - 1 {
|
||||
STEPS
|
||||
} else {
|
||||
STEPS - 1
|
||||
};
|
||||
for s in 0..=last {
|
||||
let t = s as f64 / STEPS as f64;
|
||||
let point_at = |t: f64| {
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
let mut q = [0.0f64; 3];
|
||||
for k in 0..3 {
|
||||
|
|
@ -253,8 +228,24 @@ fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[
|
|||
+ (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2
|
||||
+ (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3);
|
||||
}
|
||||
out.push(q);
|
||||
}
|
||||
q
|
||||
};
|
||||
let tangent_at = |t: f64| {
|
||||
let mut q = [0.0; 3];
|
||||
for k in 0..3 {
|
||||
let a = -p0[k] + p2[k];
|
||||
let b = 2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k];
|
||||
let c = -p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k];
|
||||
q[k] = 0.5 * (a + 2.0 * b * t + 3.0 * c * t * t);
|
||||
}
|
||||
q
|
||||
};
|
||||
let sampled = acadrust::kernel::tessellation::sample_curve3_angle(
|
||||
point_at,
|
||||
tangent_at,
|
||||
acadrust::kernel::tessellation::DEFAULT_ANGLE,
|
||||
);
|
||||
out.extend(sampled.into_iter().skip(usize::from(seg > 0)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -340,12 +331,9 @@ fn fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> {
|
|||
}
|
||||
|
||||
// Evaluate each segment as a cubic Hermite (dP/du = slope · h_i).
|
||||
const STEPS: usize = 32;
|
||||
let mut out = Vec::with_capacity((n - 1) * STEPS + 1);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n - 1 {
|
||||
let last = if i == n - 2 { STEPS } else { STEPS - 1 };
|
||||
for s in 0..=last {
|
||||
let u = s as f64 / STEPS as f64;
|
||||
let point_at = |u: f64| {
|
||||
let (u2, u3) = (u * u, u * u * u);
|
||||
let h00 = 2.0 * u3 - 3.0 * u2 + 1.0;
|
||||
let h10 = u3 - 2.0 * u2 + u;
|
||||
|
|
@ -357,8 +345,30 @@ fn fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> {
|
|||
let m1 = slopes[k][i + 1] * h[i];
|
||||
q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1;
|
||||
}
|
||||
out.push(q);
|
||||
}
|
||||
q
|
||||
};
|
||||
let tangent_at = |u: f64| {
|
||||
let u2 = u * u;
|
||||
let (h00, h10, h01, h11) = (
|
||||
6.0 * u2 - 6.0 * u,
|
||||
3.0 * u2 - 4.0 * u + 1.0,
|
||||
-6.0 * u2 + 6.0 * u,
|
||||
3.0 * u2 - 2.0 * u,
|
||||
);
|
||||
let mut q = [0.0; 3];
|
||||
for k in 0..3 {
|
||||
let m0 = slopes[k][i] * h[i];
|
||||
let m1 = slopes[k][i + 1] * h[i];
|
||||
q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1;
|
||||
}
|
||||
q
|
||||
};
|
||||
let sampled = acadrust::kernel::tessellation::sample_curve3_angle(
|
||||
point_at,
|
||||
tangent_at,
|
||||
acadrust::kernel::tessellation::DEFAULT_ANGLE,
|
||||
);
|
||||
out.extend(sampled.into_iter().skip(usize::from(i > 0)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
|
|||
10
src/scene/cache/block_cache.rs
vendored
10
src/scene/cache/block_cache.rs
vendored
|
|
@ -1961,17 +1961,11 @@ fn transform_tangent(
|
|||
}
|
||||
}
|
||||
|
||||
/// Radius / coordinate cap above which adaptive curve tessellation will
|
||||
/// allocate hundreds of millions of points. `parameter_division` samples
|
||||
/// to a fixed chord tolerance, so a Circle of radius 1e10 already produces
|
||||
/// tens of millions of points.
|
||||
/// Coordinate cap for invalid or impractical extents.
|
||||
const SANE_EXTENT: f64 = 1.0e8;
|
||||
|
||||
fn is_unreasonable_extent(e: &EntityType) -> bool {
|
||||
// Adaptive curve tessellation also explodes on degenerate primitives
|
||||
// (radius = 0, axes of length 0): `parameter_division` allocates
|
||||
// proportional to range/tolerance, which underflows when the curve
|
||||
// collapses to a point. Drop both ends of the spectrum.
|
||||
// Drop degenerate primitives and impractical coordinate ranges.
|
||||
match e {
|
||||
EntityType::Circle(c) => c.radius.abs() < 1.0e-9 || c.radius.abs() > SANE_EXTENT,
|
||||
EntityType::Arc(a) => a.radius.abs() < 1.0e-9 || a.radius.abs() > SANE_EXTENT,
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ use acadrust::kernel::brep;
|
|||
use crate::scene::convert::solid3d_tess::{body_transform, finalize_mesh};
|
||||
use crate::scene::model::mesh_model::{CurvedGen, MeshLodSet};
|
||||
|
||||
/// Relative chord tolerance, resolved once per body.
|
||||
const CHORD_FRAC: f64 = 0.002;
|
||||
|
||||
/// ACIS topology fit tolerance.
|
||||
const TOL: f64 = 1e-6;
|
||||
|
||||
|
|
@ -56,7 +53,7 @@ pub fn tessellate_sat(
|
|||
} else {
|
||||
1.0
|
||||
};
|
||||
let frac = CHORD_FRAC / resolution;
|
||||
let max_angle = acadrust::kernel::tessellation::DEFAULT_ANGLE / resolution;
|
||||
|
||||
// Positions stay f64 until `finalize_mesh` splits them into the coarse
|
||||
// and fine pair, so a solid at survey coordinates keeps its millimetres.
|
||||
|
|
@ -101,7 +98,7 @@ pub fn tessellate_sat(
|
|||
// parameters leaves a hole, the same as one that never lifted — so both
|
||||
// are counted before calling the mesh whole.
|
||||
let mut undrawn = 0usize;
|
||||
let tolerance = brep::mesh::TessellationTolerance::relative(frac, TOL)
|
||||
let tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL)
|
||||
.with_isolines(isolines);
|
||||
for body in &bodies {
|
||||
let tessellation = brep::mesh::tessellate(body, tolerance);
|
||||
|
|
@ -237,23 +234,15 @@ mod tests {
|
|||
assert_eq!(moved, [10.0, 20.0, 30.0]);
|
||||
}
|
||||
|
||||
/// How many sides a circle of `radius` gets at a sag of `frac × radius`.
|
||||
fn sides(frac: f64) -> f64 {
|
||||
let step = 2.0 * (1.0 - frac).clamp(-1.0, 1.0).acos();
|
||||
std::f64::consts::TAU / step
|
||||
fn sides(max_angle: f64) -> f64 {
|
||||
std::f64::consts::TAU / max_angle
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_surface_gets_the_same_sides_whatever_its_size() {
|
||||
// The fault: `facet_res` is a resolution multiplier and was used as a
|
||||
// sag in world units. At the default that asked for a whole unit of
|
||||
// departure, so nothing smaller than a metre subdivided at all and a
|
||||
// pipe came out as coarse as its own rim.
|
||||
//
|
||||
// A fraction of the radius carries no unit, so a bolt and a pipeline
|
||||
// are sampled alike.
|
||||
assert!(sides(CHORD_FRAC) > 24.0, "{}", sides(CHORD_FRAC));
|
||||
assert!(sides(CHORD_FRAC) < 96.0, "{}", sides(CHORD_FRAC));
|
||||
let angle = acadrust::kernel::tessellation::DEFAULT_ANGLE;
|
||||
assert!(sides(angle) > 24.0, "{}", sides(angle));
|
||||
assert!(sides(angle) < 96.0, "{}", sides(angle));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
// How finely a curve is sampled, for the whole of one render frame.
|
||||
//
|
||||
// Every curve in a drawing is tessellated to a chord height — how far the
|
||||
// polyline drawn for it may sit from the curve itself — and the right value
|
||||
// depends on the zoom. At a metre per pixel a millimetre of error is
|
||||
// invisible; zoomed to a millimetre per pixel it is the whole picture.
|
||||
//
|
||||
// The Scene sets this once per frame from `world_per_pixel`, targeting about
|
||||
// half a pixel. Every tessellation inside that frame reads the same atomic,
|
||||
// including the ones running on rayon workers, which is why it is a global
|
||||
// rather than a parameter: threading a tolerance through every entity
|
||||
// converter's signature would touch every one of them to say the same thing.
|
||||
//
|
||||
// Zero means "no frame is being drawn" — a load, a snap, a hit test — and the
|
||||
// floor is used instead. That is what `BlockCache::build` expects.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// The finest a curve is ever sampled, in world units, and the value used
|
||||
/// when no frame has set one.
|
||||
const CURVE_TOL: f64 = 0.005;
|
||||
|
||||
static CURVE_TOL_BITS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Sets the per-frame curve tolerance. `None` — or anything not finite and
|
||||
/// positive — reverts to the floor.
|
||||
pub fn set_curve_tol_override(tol: Option<f64>) {
|
||||
let bits = match tol {
|
||||
Some(value) if value > 0.0 && value.is_finite() => value.to_bits(),
|
||||
_ => 0,
|
||||
};
|
||||
CURVE_TOL_BITS.store(bits, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The tolerance to sample a curve of a given size at.
|
||||
///
|
||||
/// A chord tolerance is an absolute length, so a fixed floor carries an
|
||||
/// assumption about the drawing's units. Five thousandths is a fine sampling
|
||||
/// for a part measured in millimetres and a coarse one for a building
|
||||
/// measured in metres — it turns a ten-centimetre pipe into a decagon and a
|
||||
/// five-centimetre bolt into a heptagon, and zooming in cannot recover them,
|
||||
/// because the floor is what stops the frame asking for better.
|
||||
///
|
||||
/// Bounded against the curve's own size instead, which carries no unit. The
|
||||
/// two ends are worth stating as segment counts, since that is what they
|
||||
/// really are: a circle gets at least about fifty sides and never more than
|
||||
/// about a hundred and sixty, wherever the camera is and whatever the drawing
|
||||
/// is measured in.
|
||||
pub(crate) fn curve_tol_for(size: f64) -> f64 {
|
||||
if !(size > 0.0) || !size.is_finite() {
|
||||
return current_curve_tol();
|
||||
}
|
||||
// The frame's own request, before the absolute floor — that floor is the
|
||||
// very thing being replaced here, and letting it through first would pin
|
||||
// every curve to it however close the camera came.
|
||||
let bits = CURVE_TOL_BITS.load(Ordering::Relaxed);
|
||||
let asked = if bits == 0 {
|
||||
// Nothing is being drawn — a load, a snap, a hit test. Ask for the
|
||||
// middle of the range, which is smooth without being extravagant.
|
||||
size / 1_500.0
|
||||
} else {
|
||||
f64::from_bits(bits)
|
||||
};
|
||||
asked.clamp(size / 5_000.0, size / 500.0)
|
||||
}
|
||||
|
||||
/// The tolerance to sample at, never below the floor — so zooming a long way
|
||||
/// in cannot ask for a sampling finer than the baseline quality.
|
||||
pub(crate) fn current_curve_tol() -> f64 {
|
||||
let bits = CURVE_TOL_BITS.load(Ordering::Relaxed);
|
||||
if bits == 0 {
|
||||
CURVE_TOL
|
||||
} else {
|
||||
f64::from_bits(bits).max(CURVE_TOL)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Some(tol)` only while a frame's override is in force — that is, while
|
||||
/// something is being drawn rather than loaded, snapped or hit-tested. Hatch
|
||||
/// boundaries use it to decide whether zoom-adaptive sampling applies at all.
|
||||
pub(crate) fn active_curve_tol() -> Option<f64> {
|
||||
let bits = CURVE_TOL_BITS.load(Ordering::Relaxed);
|
||||
(bits != 0).then(|| f64::from_bits(bits).max(CURVE_TOL))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// How many chords a circle of `radius` needs at chord height `tol`.
|
||||
///
|
||||
/// From the sagitta: a chord subtending `θ` departs from the arc by
|
||||
/// `r(1 − cos(θ/2))` in the middle.
|
||||
fn sides(radius: f64, tol: f64) -> f64 {
|
||||
let step = 2.0 * (1.0 - tol / radius).clamp(-1.0, 1.0).acos();
|
||||
std::f64::consts::TAU / step
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_gets_the_same_number_of_sides_whatever_it_is_measured_in() {
|
||||
// The fault: the floor was an absolute length, so the same circle drawn
|
||||
// in millimetres and in metres came out smooth and faceted. Ten
|
||||
// centimetres of pipe had ten sides.
|
||||
set_curve_tol_override(None);
|
||||
let in_millimetres = sides(100.0, curve_tol_for(100.0));
|
||||
let in_metres = sides(0.1, curve_tol_for(0.1));
|
||||
assert!(
|
||||
(in_millimetres - in_metres).abs() < 1.0,
|
||||
"{in_millimetres} vs {in_metres}"
|
||||
);
|
||||
assert!(in_metres > 40.0, "a circle should not read as a decagon: {in_metres}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_in_buys_detail_and_zooming_out_does_not_cost_it() {
|
||||
// Between the two bounds the frame decides, so a curve fills in as the
|
||||
// camera closes on it.
|
||||
set_curve_tol_override(Some(0.01));
|
||||
let far = sides(1.0, curve_tol_for(1.0));
|
||||
set_curve_tol_override(Some(0.0001));
|
||||
let near = sides(1.0, curve_tol_for(1.0));
|
||||
assert!(near > far, "{near} vs {far}");
|
||||
// And neither end runs away: a circle is never a polygon and never a
|
||||
// thousand-sided one that costs more than it shows.
|
||||
set_curve_tol_override(Some(1e-12));
|
||||
assert!(sides(1.0, curve_tol_for(1.0)) < 200.0);
|
||||
set_curve_tol_override(Some(1e9));
|
||||
assert!(sides(1.0, curve_tol_for(1.0)) > 40.0);
|
||||
set_curve_tol_override(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_with_no_size_is_left_to_the_frame() {
|
||||
// A straight run needs its two ends; there is no curvature to sample.
|
||||
set_curve_tol_override(Some(0.02));
|
||||
assert_eq!(curve_tol_for(0.0), current_curve_tol());
|
||||
assert_eq!(curve_tol_for(f64::NAN), current_curve_tol());
|
||||
set_curve_tol_override(None);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ pub mod acad_to_render;
|
|||
pub mod acis_export;
|
||||
pub mod acis_kernel;
|
||||
pub mod dgn_linestyle;
|
||||
pub mod curve_tol;
|
||||
pub mod tessellate;
|
||||
pub(crate) mod tess;
|
||||
pub mod proxy_graphics;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
// Shared helpers used by per-entity tessellation impls in `crate::entities`
|
||||
// and the dispatcher in `crate::scene::convert::tessellate`.
|
||||
//
|
||||
// Anything cross-entity (colour resolution, arc sampling, chord tolerance)
|
||||
// lives here. Entity-specific helpers (dim format strings, mleader path
|
||||
// types, etc.) stay with their entity file.
|
||||
// Cross-entity rendering helpers live here.
|
||||
|
||||
use acadrust::types::Color as AcadColor;
|
||||
use glam::Vec3;
|
||||
|
|
@ -28,81 +26,6 @@ pub type FallbackGeometry = (
|
|||
Vec<[f64; 3]>,
|
||||
);
|
||||
|
||||
// ── Arc tessellation helpers ─────────────────────────────────────────────
|
||||
|
||||
/// Convert hatch-boundary arc `(start, end, ccw)` into a
|
||||
/// `(start, signed_span)` ready for the sampling loop. Matches the
|
||||
/// legacy `(TAU - sa, TAU - ea)` flip used here for years — direction
|
||||
/// semantics are preserved on real files. (Wrap-through-2π is a known
|
||||
/// edge case in that convention; do not "fix" it without a wider audit
|
||||
/// of how upstream writers emit CW boundary arcs.)
|
||||
///
|
||||
/// VERIFIED against real AutoCAD output (KSR-039 DWG, CW arc edges with
|
||||
/// line neighbours): the stored angles of a `ccw = false` arc edge are
|
||||
/// MIRRORED — the true point is `center + r·(cos(TAU-θ), sin(TAU-θ))`.
|
||||
/// Sampling the mirrored parameter directly (this function's flip) gives
|
||||
/// exact endpoint continuity with the adjacent edges (Δ = 0.000000),
|
||||
/// while interpreting them as true angles lands tens of units away. Any
|
||||
/// code that *produces* CW boundary arcs (mirror transforms, explode)
|
||||
/// must therefore store mirrored angles, not geometric ones.
|
||||
pub fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) {
|
||||
const TAU: f64 = std::f64::consts::TAU;
|
||||
let (sa, ea) = if ccw {
|
||||
(start, end)
|
||||
} else {
|
||||
(TAU - start, TAU - end)
|
||||
};
|
||||
(sa, ea - sa)
|
||||
}
|
||||
|
||||
/// Segment count for an arc, targeting `chord_tol_world` chord-height
|
||||
/// error in world units. Floor 8, cap 512.
|
||||
///
|
||||
/// Two production callers:
|
||||
/// - hatch fill polygon (built at load / on edit) passes a radius-
|
||||
/// relative tol via [`fill_chord_tol`] — ~0.1% radius, zoom-free so
|
||||
/// the polygon stays sharp at extreme zoom-in without re-tessellation.
|
||||
/// - hatch wire outline (re-tessellated every frame inside the render
|
||||
/// scope) passes a zoom-adaptive tol via [`wire_chord_tol`] — pulls
|
||||
/// from the per-frame override set by `Scene::wires_for_block` so
|
||||
/// far-out arcs collapse to a handful of segments.
|
||||
pub fn arc_segments(radius: f64, span_abs: f64, chord_tol_world: f64) -> u32 {
|
||||
arc_segments_floored(radius, span_abs, chord_tol_world, 8)
|
||||
}
|
||||
|
||||
/// Like [`arc_segments`] but with a caller-chosen segment floor. The 2-D wire
|
||||
/// wants floor 8 so a circle reads as round; a curved-surface grid patch or a
|
||||
/// short arc wants a lower floor so a small span isn't forced to 8 steps. Cap
|
||||
/// stays 512.
|
||||
pub fn arc_segments_floored(radius: f64, span_abs: f64, chord_tol_world: f64, floor: u32) -> u32 {
|
||||
if span_abs < 1e-9 || radius < 1e-9 {
|
||||
return floor.max(1);
|
||||
}
|
||||
let tol = chord_tol_world.max(1e-9).min(radius * 0.99);
|
||||
// θ where r * (1 - cos(θ/2)) = tol → θ = 2 * acos(1 - tol/r).
|
||||
let max_step = (2.0 * (1.0 - tol / radius).acos()).max(1e-6);
|
||||
((span_abs / max_step).ceil() as u32).clamp(floor, 512)
|
||||
}
|
||||
|
||||
/// Chord tolerance for the load-time fill polygon: 0.1% of radius,
|
||||
/// floor 1 µm so degenerate radii still produce a workable count.
|
||||
pub fn fill_chord_tol(radius: f64) -> f64 {
|
||||
(radius * 0.001).max(1e-6)
|
||||
}
|
||||
|
||||
/// Chord tolerance for the per-frame wire outline: pulls the active
|
||||
/// `curve_tol::set_curve_tol_override` value (Scene sets it to
|
||||
/// `world_per_pixel × 0.5` so curves stay at ~half-pixel chord error at
|
||||
/// the current zoom). When no override is active (snap / hit-test
|
||||
/// passes, load-time builds), falls back to [`fill_chord_tol`] so we
|
||||
/// never under-sample.
|
||||
pub fn wire_chord_tol(radius: f64) -> f64 {
|
||||
match crate::scene::convert::curve_tol::active_curve_tol() {
|
||||
Some(t) => t.min(fill_chord_tol(radius)).max(1e-9),
|
||||
None => fill_chord_tol(radius),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Colour helper ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert an acadrust Color (ACI index or true-color) to a GPU RGBA value.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// EntityType
|
||||
// ↓ acad_to_render::convert()
|
||||
// RenderEntity { object: RenderObject, snap_pts, tangent_geoms, key_vertices }
|
||||
// ↓ curve_tol::tessellate_*()
|
||||
// ↓
|
||||
// RenderObject::Lines → WireModel
|
||||
// RenderObject::Dot → WireModel (a dot sized in pixels)
|
||||
// RenderObject::Text → WireModel (glyph strokes) + SDF quads
|
||||
|
|
|
|||
|
|
@ -1398,195 +1398,14 @@ impl Scene {
|
|||
|
||||
let mut edge_polys: Vec<Vec<[f64; 2]>> = Vec::new();
|
||||
for edge in &path.edges {
|
||||
match edge {
|
||||
BoundaryEdge::Polyline(poly) => {
|
||||
let verts = &poly.vertices;
|
||||
let count = verts.len();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let seg_count = if poly.is_closed {
|
||||
count
|
||||
} else {
|
||||
count.saturating_sub(1)
|
||||
};
|
||||
for i in 0..seg_count {
|
||||
let v0 = &verts[i];
|
||||
let v1 = &verts[(i + 1) % count];
|
||||
let bulge = v0.z;
|
||||
// Tess in f64 to preserve ~1 cm precision at
|
||||
// UTM-scale WCS (the f32 path used to produce
|
||||
// visibly wavy hatch arcs at 1e5+ magnitude).
|
||||
let arc = if bulge.abs() < 1e-9 {
|
||||
None
|
||||
} else {
|
||||
crate::entities::common::BulgeArc::from_bulge(
|
||||
[v0.x, v0.y],
|
||||
[v1.x, v1.y],
|
||||
bulge,
|
||||
)
|
||||
};
|
||||
let Some(arc) = arc else {
|
||||
boundary.push(to_xy(v0.x, v0.y));
|
||||
continue;
|
||||
};
|
||||
let segs = convert::tess_util::arc_segments(
|
||||
arc.radius,
|
||||
arc.sweep.abs(),
|
||||
convert::tess_util::fill_chord_tol(arc.radius),
|
||||
);
|
||||
for j in 0..segs {
|
||||
let s = arc.sample(j as f64 / segs as f64);
|
||||
boundary.push(to_xy(s[0], s[1]));
|
||||
}
|
||||
}
|
||||
if poly.is_closed {
|
||||
if let Some(&first) = boundary.get(path_start) {
|
||||
boundary.push(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
BoundaryEdge::Line(line) => {
|
||||
edge_polys.push(vec![
|
||||
to_xy(line.start.x, line.start.y),
|
||||
to_xy(line.end.x, line.end.y),
|
||||
]);
|
||||
}
|
||||
BoundaryEdge::CircularArc(arc) => {
|
||||
let (sa, span) = convert::tess_util::arc_signed_span(
|
||||
arc.start_angle,
|
||||
arc.end_angle,
|
||||
arc.counter_clockwise,
|
||||
);
|
||||
let segs = convert::tess_util::arc_segments(
|
||||
arc.radius,
|
||||
span.abs(),
|
||||
convert::tess_util::fill_chord_tol(arc.radius),
|
||||
);
|
||||
let mut pts = Vec::with_capacity(segs as usize + 1);
|
||||
for i in 0..=segs {
|
||||
let t = sa + span * (i as f64 / segs as f64);
|
||||
pts.push(to_xy(
|
||||
arc.center.x + arc.radius * t.cos(),
|
||||
arc.center.y + arc.radius * t.sin(),
|
||||
));
|
||||
}
|
||||
edge_polys.push(pts);
|
||||
}
|
||||
BoundaryEdge::EllipticArc(ell) => {
|
||||
let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x
|
||||
+ ell.major_axis_endpoint.y * ell.major_axis_endpoint.y)
|
||||
.sqrt();
|
||||
let r_min = r_maj * ell.minor_axis_ratio;
|
||||
let rot = ell
|
||||
.major_axis_endpoint
|
||||
.y
|
||||
.atan2(ell.major_axis_endpoint.x);
|
||||
let (sa, span) = convert::tess_util::arc_signed_span(
|
||||
ell.start_angle,
|
||||
ell.end_angle,
|
||||
ell.counter_clockwise,
|
||||
);
|
||||
let segs = convert::tess_util::arc_segments(
|
||||
r_maj,
|
||||
span.abs(),
|
||||
convert::tess_util::fill_chord_tol(r_maj),
|
||||
);
|
||||
let (cr, sr) = (rot.cos(), rot.sin());
|
||||
let mut pts = Vec::with_capacity(segs as usize + 1);
|
||||
for i in 0..=segs {
|
||||
let t = sa + span * (i as f64 / segs as f64);
|
||||
let lx = r_maj * t.cos();
|
||||
let ly = r_min * t.sin();
|
||||
pts.push(to_xy(
|
||||
ell.center.x + lx * cr - ly * sr,
|
||||
ell.center.y + lx * sr + ly * cr,
|
||||
));
|
||||
}
|
||||
edge_polys.push(pts);
|
||||
}
|
||||
BoundaryEdge::Spline(spline) => {
|
||||
// DXF spline control_points pack (x, y, weight) into
|
||||
// a Vector3 — the z field is the rational weight, NOT
|
||||
// a Z coordinate. The legacy code dropped weight and
|
||||
// sampled with a fixed 16 segments; both bugs
|
||||
// produced visibly wrong fill regions for spline-
|
||||
// bounded hatches (especially block-internal ones,
|
||||
// where boundaries are often spline curves with
|
||||
// rational weights and short cubic segments).
|
||||
//
|
||||
// The kernel's plane NURBS holds the rational and
|
||||
// the polynomial case alike — weights absent means
|
||||
// polynomial — and tessellates to a chord tolerance,
|
||||
// the same one the fill polygon uses for arcs.
|
||||
let degree = spline.degree.max(0) as usize;
|
||||
|
||||
// Rough chord-tolerance: 0.1% of the control-poly
|
||||
// diagonal so adaptive sampling produces enough
|
||||
// points to follow the curve without exploding on
|
||||
// huge splines.
|
||||
let (mut sp_min_x, mut sp_min_y) = (f64::INFINITY, f64::INFINITY);
|
||||
let (mut sp_max_x, mut sp_max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for cp in &spline.control_points {
|
||||
sp_min_x = sp_min_x.min(cp.x);
|
||||
sp_min_y = sp_min_y.min(cp.y);
|
||||
sp_max_x = sp_max_x.max(cp.x);
|
||||
sp_max_y = sp_max_y.max(cp.y);
|
||||
}
|
||||
let diag = ((sp_max_x - sp_min_x).powi(2)
|
||||
+ (sp_max_y - sp_min_y).powi(2))
|
||||
.sqrt();
|
||||
let tol = convert::tess_util::fill_chord_tol(diag.max(1.0));
|
||||
|
||||
let mut epts: Vec<[f64; 2]> = Vec::new();
|
||||
let mut sampled = false;
|
||||
let points: Vec<[f64; 2]> = spline
|
||||
.control_points
|
||||
.iter()
|
||||
.map(|p| [p.x, p.y])
|
||||
.collect();
|
||||
let weights = spline.rational.then(|| {
|
||||
spline
|
||||
.control_points
|
||||
.iter()
|
||||
.map(|p| if p.z.abs() > 1e-12 { p.z } else { 1.0 })
|
||||
.collect()
|
||||
});
|
||||
if let Some(curve) = acadrust::kernel::geom2d::NurbsCurve::new(
|
||||
degree,
|
||||
points,
|
||||
spline.knots.clone(),
|
||||
weights,
|
||||
) {
|
||||
for point in
|
||||
acadrust::kernel::geom2d::Curve::Nurbs(curve).tessellate_within(tol)
|
||||
{
|
||||
epts.push(to_xy(point[0], point[1]));
|
||||
}
|
||||
sampled = epts.len() >= 2;
|
||||
}
|
||||
if !sampled {
|
||||
// Fallback: prefer fit_points (which lie on the
|
||||
// curve) over control_points (which usually
|
||||
// don't). A control-point polyline would draw
|
||||
// the convex-hull silhouette — visibly wrong.
|
||||
let pts: &[_] = if !spline.fit_points.is_empty() {
|
||||
&spline.fit_points
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
if !pts.is_empty() {
|
||||
for p in pts {
|
||||
epts.push(to_xy(p.x, p.y));
|
||||
}
|
||||
} else {
|
||||
for cp in &spline.control_points {
|
||||
epts.push(to_xy(cp.x, cp.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
edge_polys.push(epts);
|
||||
}
|
||||
if let Some(curve) = crate::entities::hatch::edge_curve(edge) {
|
||||
edge_polys.push(
|
||||
curve
|
||||
.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE)
|
||||
.into_iter()
|
||||
.map(|point| to_xy(point[0], point[1]))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
boundary.extend(chain_path_edges(edge_polys));
|
||||
|
|
|
|||
|
|
@ -8207,21 +8207,6 @@ impl Scene {
|
|||
let paper = anno_scale_override.is_some();
|
||||
let blk_cache = self.block_cache_arc_for(annotation_scale_handle, all_visible, style_viewport);
|
||||
let blk_ref: &cache::block_cache::BlockCache = &blk_cache;
|
||||
// Zoom-adaptive curve sampling for top-level Edge tessellation. Target
|
||||
// ~0.5 px chord height — far-out arcs that used to emit hundreds of
|
||||
// segments now collapse to a handful. The guard clears the override
|
||||
// when this scope exits so off-render tessellation (snap previews,
|
||||
// hit-test, block_cache rebuild) sees the default.
|
||||
struct CurveTolGuard;
|
||||
impl Drop for CurveTolGuard {
|
||||
fn drop(&mut self) {
|
||||
crate::scene::convert::curve_tol::set_curve_tol_override(None);
|
||||
}
|
||||
}
|
||||
let _tol_guard = wpp.map(|w| {
|
||||
crate::scene::convert::curve_tol::set_curve_tol_override(Some((w * 0.5) as f64));
|
||||
CurveTolGuard
|
||||
});
|
||||
// Per-entity tessellation memo. Same classify/tessellate logic, two
|
||||
// SEPARATE stores so they can't thrash each other:
|
||||
// * culled path (`view_aabb == Some`) → `tess_memo`, guard keyed on the
|
||||
|
|
|
|||
|
|
@ -17,16 +17,17 @@ use acadrust::kernel::brep::{self, Body};
|
|||
|
||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
|
||||
/// How far a triangle may sit from the surface it lies on. A drawing is
|
||||
/// measured in millimetres, and a twentieth of one is past what a screen
|
||||
/// resolves at any sane zoom.
|
||||
const SAG: f64 = 0.05;
|
||||
|
||||
/// What counts as the same point when the kernel checks a body over.
|
||||
const TOL: f64 = 1e-9;
|
||||
|
||||
fn tessellation(body: &Body) -> brep::mesh::BodyMesh {
|
||||
brep::mesh::tessellate(body, brep::mesh::TessellationTolerance::new(SAG, TOL))
|
||||
brep::mesh::tessellate(
|
||||
body,
|
||||
brep::mesh::TessellationTolerance::new(
|
||||
acadrust::kernel::tessellation::DEFAULT_ANGLE,
|
||||
TOL,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Axis-aligned box from its center and full extents.
|
||||
|
|
|
|||
|
|
@ -112,26 +112,26 @@ use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
|||
|
||||
/// SWEEP through the kernel's tolerance-driven mesh API.
|
||||
pub fn swept(profile: &EntityType, path: &EntityType, color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
let tolerance = crate::scene::convert::curve_tol::current_curve_tol();
|
||||
let max_angle = acadrust::kernel::tessellation::DEFAULT_ANGLE;
|
||||
let surface = brep::mesh::sweep_surface(
|
||||
&entity_curve(profile)?,
|
||||
&entity_curve(path)?,
|
||||
tolerance,
|
||||
max_angle,
|
||||
)?;
|
||||
mesh_set(surface, color, tolerance)
|
||||
mesh_set(surface, color, 1e-9)
|
||||
}
|
||||
|
||||
/// LOFT through the kernel's tolerance-driven mesh API.
|
||||
pub fn lofted(profiles: &[EntityType], color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
let curves: Vec<PlanarCurve> = profiles.iter().filter_map(entity_curve).collect();
|
||||
let tolerance = crate::scene::convert::curve_tol::current_curve_tol();
|
||||
mesh_set(brep::mesh::loft_surface(&curves, tolerance)?, color, tolerance)
|
||||
let max_angle = acadrust::kernel::tessellation::DEFAULT_ANGLE;
|
||||
mesh_set(brep::mesh::loft_surface(&curves, max_angle)?, color, 1e-9)
|
||||
}
|
||||
|
||||
fn mesh_set(
|
||||
surface: brep::mesh::SurfaceMesh,
|
||||
color: [f32; 4],
|
||||
tolerance: f64,
|
||||
precision: f64,
|
||||
) -> Option<MeshLodSet> {
|
||||
if surface.mesh.is_empty() {
|
||||
return None;
|
||||
|
|
@ -141,7 +141,7 @@ fn mesh_set(
|
|||
for point in &surface.mesh.positions {
|
||||
push_point(&mut verts, &mut verts_low, *point);
|
||||
}
|
||||
let silhouette = surface.silhouette_source(tolerance);
|
||||
let silhouette = surface.silhouette_source(precision);
|
||||
let mut set = MeshLodSet::from_single(MeshModel {
|
||||
name: String::new(),
|
||||
verts,
|
||||
|
|
|
|||
Loading…
Reference in a new issue