refactor(solid3d): drive curved-face sampling from one chord tolerance
Curved 3-D solid faces used fixed per-LOD grid counts, so a partial arc (a sphere cap, a torus C-arc, a short cone wall) was sampled as finely as a whole rim, and the density knobs were scattered across four files. Every curved face (sphere / cone / torus / spline), feature edge and isoline now derives its segment count from a chord-height tolerance plus the arc's own radius and span — the same model the 2-D circle/arc/ellipse wires use (tess_util::arc_segments): a big rim samples finer than a small one, a short arc proportionally less than a full turn, and facet error is size-independent. All tolerances live in one block at the top of solid3d_tess: LodConfig is now a single chord fraction per LOD (arc_segs / circle_segs derive counts), plus EDGE_CHORD_FRAC, TRUCK_CHORD_FRAC and BOUNDARY_CHORD_FRAC. FACETRES scales the fraction. The cone height is a straight generator (no curvature) so it meshes in one division. Removes the scattered constants (circ_segs/grid_u/grid_v, CURVE_REL_TOL, BOUNDARY_SEGS, per-site M / EDGE_SEGS / ring_segs). Adaptive counts come out lower, not higher: the € solid drops 3056→1780 tris, a full sphere stays at 1024, five-solid total is 2989. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
570165e77f
commit
608cf8c74b
4 changed files with 185 additions and 92 deletions
|
|
@ -30,22 +30,16 @@ use acadrust::entities::acis::{
|
||||||
|
|
||||||
use crate::scene::convert::solid3d_tess::{
|
use crate::scene::convert::solid3d_tess::{
|
||||||
apply_body_transform, body_transform, collect_face_loops, cone_axis_span, tess_cone_face,
|
apply_body_transform, body_transform, collect_face_loops, cone_axis_span, tess_cone_face,
|
||||||
tess_plane_face, tess_sphere_face, tess_torus_face, LodConfig,
|
tess_plane_face, tess_sphere_face, tess_torus_face, LodConfig, BOUNDARY_CHORD_FRAC,
|
||||||
|
TRUCK_CHORD_FRAC,
|
||||||
};
|
};
|
||||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||||
|
|
||||||
/// Slightly over 2π so revolution builders close the loop.
|
/// Slightly over 2π so revolution builders close the loop.
|
||||||
const FULL: f64 = std::f64::consts::TAU + 0.2;
|
const FULL: f64 = std::f64::consts::TAU + 0.2;
|
||||||
/// Boundary sampling density for planar faces with curved (circular) edges.
|
|
||||||
const BOUNDARY_SEGS: usize = 64;
|
|
||||||
/// Triangle mesh chord tolerance (world units) for planar faces, where the
|
/// Triangle mesh chord tolerance (world units) for planar faces, where the
|
||||||
/// surface itself adds no curvature.
|
/// surface itself adds no curvature.
|
||||||
const MESH_TOL: f64 = 0.01;
|
const MESH_TOL: f64 = 0.01;
|
||||||
/// Chord tolerance for curved faces, as a fraction of the surface radius, so
|
|
||||||
/// the facet count is radius-independent instead of exploding on large radii.
|
|
||||||
/// `0.1` = 10% chord error (~7 facets per full circle) — a deliberately coarse
|
|
||||||
/// setting that keeps the solid triangle budget low.
|
|
||||||
const CURVE_REL_TOL: f64 = 0.1;
|
|
||||||
|
|
||||||
/// Analytic outward-normal rule for a face, used to orient triangles and
|
/// Analytic outward-normal rule for a face, used to orient triangles and
|
||||||
/// supply smooth per-vertex normals (truck's face orientation is unreliable
|
/// supply smooth per-vertex normals (truck's face orientation is unreliable
|
||||||
|
|
@ -154,7 +148,7 @@ fn bespoke_face(
|
||||||
match surf_rec.entity_type.as_str() {
|
match surf_rec.entity_type.as_str() {
|
||||||
"plane-surface" => {
|
"plane-surface" => {
|
||||||
if let Some(p) = SatPlaneSurface::from_record(surf_rec) {
|
if let Some(p) = SatPlaneSurface::from_record(surf_rec) {
|
||||||
tess_plane_face(sat, face, &p, LodConfig::HIGH.circ_segs, v, n, i);
|
tess_plane_face(sat, face, &p, LodConfig::HIGH.chord_frac, v, n, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"cone-surface" => {
|
"cone-surface" => {
|
||||||
|
|
@ -185,7 +179,7 @@ fn build_face_group(
|
||||||
// Chord tolerance scaled to a curved surface's radius, so the facet count
|
// Chord tolerance scaled to a curved surface's radius, so the facet count
|
||||||
// is radius-independent (matching the circle/arc wire tessellation) rather
|
// is radius-independent (matching the circle/arc wire tessellation) rather
|
||||||
// than exploding on large radii.
|
// than exploding on large radii.
|
||||||
let curve_tol = |radius: f64| (radius.abs() * CURVE_REL_TOL).max(1e-6);
|
let curve_tol = |radius: f64| (radius.abs() * TRUCK_CHORD_FRAC).max(1e-6);
|
||||||
match surf_rec.entity_type.as_str() {
|
match surf_rec.entity_type.as_str() {
|
||||||
"plane-surface" => {
|
"plane-surface" => {
|
||||||
let plane = SatPlaneSurface::from_record(surf_rec)?;
|
let plane = SatPlaneSurface::from_record(surf_rec)?;
|
||||||
|
|
@ -230,7 +224,7 @@ fn plane_face(sat: &SatDocument, face: &SatFace) -> Option<Face> {
|
||||||
// the first wire and cuts the rest as holes, letting truck's mesher trim
|
// the first wire and cuts the rest as holes, letting truck's mesher trim
|
||||||
// them. ACIS records no outer-vs-hole flag — the kernel classifies loops at
|
// them. ACIS records no outer-vs-hole flag — the kernel classifies loops at
|
||||||
// runtime from geometry — so we derive the outer boundary here. (#123)
|
// runtime from geometry — so we derive the outer boundary here. (#123)
|
||||||
let loops = collect_face_loops(sat, face, BOUNDARY_SEGS);
|
let loops = collect_face_loops(sat, face, BOUNDARY_CHORD_FRAC);
|
||||||
if loops.is_empty() {
|
if loops.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +337,8 @@ fn cone_faces(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Angular extent of this face from its boundary loop (seam-robust).
|
// Angular extent of this face from its boundary loop (seam-robust).
|
||||||
let poly = crate::scene::convert::solid3d_tess::collect_face_polygon(sat, face, BOUNDARY_SEGS);
|
let poly =
|
||||||
|
crate::scene::convert::solid3d_tess::collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC);
|
||||||
let (theta0, sweep) = cone_boundary_arc(&poly, [cx, cy, cz], axis, udir, vdir);
|
let (theta0, sweep) = cone_boundary_arc(&poly, [cx, cy, cz], axis, udir, vdir);
|
||||||
// Radial direction at the arc's start angle (u rotated by theta0 about axis).
|
// Radial direction at the arc's start angle (u rotated by theta0 about axis).
|
||||||
let rad0 = udir * theta0.cos() + vdir * theta0.sin();
|
let rad0 = udir * theta0.cos() + vdir * theta0.sin();
|
||||||
|
|
|
||||||
|
|
@ -23,41 +23,91 @@ use acadrust::entities::{Body, Region, Solid3D};
|
||||||
|
|
||||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||||
|
|
||||||
/// Per-LOD sampling density. Higher values = finer mesh = more triangles.
|
// ── Curved-surface sampling — SINGLE TUNING POINT ────────────────────────────
|
||||||
|
//
|
||||||
|
// Every curved 3-D face (sphere / cone / torus / spline), curved feature edge
|
||||||
|
// and isoline samples at a density derived from a chord-height tolerance — the
|
||||||
|
// same model the 2-D circle/arc/ellipse wires use (see `tess_util::arc_segments`):
|
||||||
|
// the segment count tracks the arc's own radius and span at a bounded relative
|
||||||
|
// chord error, so a partial arc samples proportionally and facet error is
|
||||||
|
// size-independent. Every knob lives in this block — edit here to trade mesh
|
||||||
|
// density against the triangle budget across the whole solid tessellator.
|
||||||
|
|
||||||
|
/// Feature edges & isolines are built once at highest detail; this chord-height
|
||||||
|
/// fraction of the curve radius sets their sampling density (~0.002 ⇒ ~50
|
||||||
|
/// segments per full circle).
|
||||||
|
pub(crate) const EDGE_CHORD_FRAC: f64 = 0.002;
|
||||||
|
/// Truck's own triangulation chord tolerance for the cone faces still routed
|
||||||
|
/// through its kernel, as a fraction of the surface radius.
|
||||||
|
pub(crate) const TRUCK_CHORD_FRAC: f64 = 0.1;
|
||||||
|
/// Boundary-loop sampling for parameter-range classification (which arc of a
|
||||||
|
/// sphere/torus a face covers): a fine fraction so the classification is
|
||||||
|
/// accurate; the points are not rendered.
|
||||||
|
pub(crate) const BOUNDARY_CHORD_FRAC: f64 = 0.002;
|
||||||
|
|
||||||
|
/// Per-LOD curved-surface sampling tolerance. A LOD is now just a chord-height
|
||||||
|
/// tolerance (fraction of the local radius); segment counts derive from it plus
|
||||||
|
/// the arc's own radius and span, so density is adaptive rather than a fixed
|
||||||
|
/// grid. Smaller fraction = finer mesh = more triangles.
|
||||||
#[derive(Copy, Clone, Debug)]
|
#[derive(Copy, Clone, Debug)]
|
||||||
pub struct LodConfig {
|
pub struct LodConfig {
|
||||||
/// Arc segments per full circle for curved-surface sampling.
|
/// Chord-height tolerance as a fraction of the local radius.
|
||||||
pub circ_segs: usize,
|
pub chord_frac: f64,
|
||||||
/// Longitudinal grid count for sphere / torus surfaces.
|
|
||||||
pub grid_u: usize,
|
|
||||||
/// Latitudinal grid count for sphere / torus surfaces.
|
|
||||||
pub grid_v: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LodConfig {
|
impl LodConfig {
|
||||||
/// LOD 0 — full resolution. The pre-Phase-3.4 baseline.
|
/// LOD 0 — full resolution (~0.5 % radius ⇒ ~32 segments per full circle,
|
||||||
pub const HIGH: LodConfig = LodConfig {
|
/// matching the pre-tolerance grid baseline).
|
||||||
circ_segs: 48,
|
pub const HIGH: LodConfig = LodConfig { chord_frac: 0.005 };
|
||||||
grid_u: 32,
|
|
||||||
grid_v: 16,
|
|
||||||
};
|
|
||||||
/// LOD 1 — half-resolution. Use between ~50–200 px projected diagonal.
|
/// LOD 1 — half-resolution. Use between ~50–200 px projected diagonal.
|
||||||
pub const MID: LodConfig = LodConfig {
|
pub const MID: LodConfig = LodConfig { chord_frac: 0.02 };
|
||||||
circ_segs: 24,
|
|
||||||
grid_u: 16,
|
|
||||||
grid_v: 8,
|
|
||||||
};
|
|
||||||
/// LOD 2 — quarter-resolution. Use below ~50 px.
|
/// LOD 2 — quarter-resolution. Use below ~50 px.
|
||||||
pub const LOW: LodConfig = LodConfig {
|
pub const LOW: LodConfig = LodConfig { chord_frac: 0.08 };
|
||||||
circ_segs: 12,
|
|
||||||
grid_u: 8,
|
|
||||||
grid_v: 4,
|
|
||||||
};
|
|
||||||
/// Returns the three LOD configs in `[high, mid, low]` order — matches
|
/// Returns the three LOD configs in `[high, mid, low]` order — matches
|
||||||
/// the `MeshLodSet::lods` slot ordering.
|
/// the `MeshLodSet::lods` slot ordering.
|
||||||
pub const fn all() -> [LodConfig; 3] {
|
pub const fn all() -> [LodConfig; 3] {
|
||||||
[Self::HIGH, Self::MID, Self::LOW]
|
[Self::HIGH, Self::MID, Self::LOW]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Segment count spanning `span_abs` radians of an arc of `radius` at this
|
||||||
|
/// LOD's chord tolerance. Floor 2 — an open grid patch needs only a step.
|
||||||
|
pub fn arc_segs(&self, radius: f64, span_abs: f64) -> usize {
|
||||||
|
crate::scene::convert::tess_util::arc_segments_floored(
|
||||||
|
radius.abs(),
|
||||||
|
span_abs,
|
||||||
|
radius.abs() * self.chord_frac,
|
||||||
|
2,
|
||||||
|
) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segment count around a full circle of `radius` at this LOD. Floor 8 so a
|
||||||
|
/// closed cross-section (a tube / minor circle) still reads as round.
|
||||||
|
pub fn circle_segs(&self, radius: f64) -> usize {
|
||||||
|
crate::scene::convert::tess_util::arc_segments_floored(
|
||||||
|
radius.abs(),
|
||||||
|
TAU,
|
||||||
|
radius.abs() * self.chord_frac,
|
||||||
|
8,
|
||||||
|
) as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segment count for a feature edge / isoline arc of `radius` spanning
|
||||||
|
/// `span_abs`, at the shared [`EDGE_CHORD_FRAC`] tolerance. Floor 4.
|
||||||
|
pub(crate) fn edge_arc_segs(radius: f64, span_abs: f64) -> usize {
|
||||||
|
crate::scene::convert::tess_util::arc_segments_floored(
|
||||||
|
radius.abs(),
|
||||||
|
span_abs,
|
||||||
|
radius.abs() * EDGE_CHORD_FRAC,
|
||||||
|
4,
|
||||||
|
) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample count for a curve with no analytic radius (a spline edge / surface):
|
||||||
|
/// the unit-circle segment count at chord fraction `frac`, used as a nominal
|
||||||
|
/// density that still tracks the LOD.
|
||||||
|
pub(crate) fn nominal_segs(frac: f64) -> usize {
|
||||||
|
crate::scene::convert::tess_util::arc_segments_floored(1.0, TAU, frac, 8) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public entry point ────────────────────────────────────────────────────────
|
// ── Public entry point ────────────────────────────────────────────────────────
|
||||||
|
|
@ -85,7 +135,7 @@ fn tessellate_sat(
|
||||||
sat,
|
sat,
|
||||||
&face,
|
&face,
|
||||||
&plane,
|
&plane,
|
||||||
lod.circ_segs,
|
lod.chord_frac,
|
||||||
&mut verts,
|
&mut verts,
|
||||||
&mut normals,
|
&mut normals,
|
||||||
&mut indices,
|
&mut indices,
|
||||||
|
|
@ -266,7 +316,7 @@ fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option<ConeFaceGeom> {
|
||||||
let u_dir = norm3([ux, uy, uz]);
|
let u_dir = norm3([ux, uy, uz]);
|
||||||
let v_dir = cross3(axis, u_dir);
|
let v_dir = cross3(axis, u_dir);
|
||||||
|
|
||||||
let poly = collect_face_polygon(sat, face, 48);
|
let poly = collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC);
|
||||||
let (mut h_min, mut h_max, mut theta_min, mut theta_max, full) =
|
let (mut h_min, mut h_max, mut theta_min, mut theta_max, full) =
|
||||||
angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly);
|
angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly);
|
||||||
if (h_max - h_min).abs() < 1e-9 {
|
if (h_max - h_min).abs() < 1e-9 {
|
||||||
|
|
@ -374,18 +424,20 @@ fn sphere_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Ve
|
||||||
sphere.u_direction().2,
|
sphere.u_direction().2,
|
||||||
]);
|
]);
|
||||||
let v = cross3(pole, u);
|
let v = cross3(pole, u);
|
||||||
let poly = collect_face_polygon(sat, face, 48);
|
let poly = collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC);
|
||||||
let (theta_min, theta_span, full, phi_min, phi_max) =
|
let (theta_min, theta_span, full, phi_min, phi_max) =
|
||||||
sphere_param_range(&poly, [cx, cy, cz], pole, u, v);
|
sphere_param_range(&poly, [cx, cy, cz], pole, u, v);
|
||||||
const M: usize = 24;
|
// Meridian subdivisions from the sphere radius and colatitude span (a great
|
||||||
|
// circle of radius `r`) at the shared edge chord tolerance.
|
||||||
|
let m = edge_arc_segs(r, phi_max - phi_min);
|
||||||
let sphere_pt = |theta: f64, phi: f64| {
|
let sphere_pt = |theta: f64, phi: f64| {
|
||||||
let d = sphere_dir(pole, u, v, theta, phi);
|
let d = sphere_dir(pole, u, v, theta, phi);
|
||||||
[cx + r * d[0], cy + r * d[1], cz + r * d[2]]
|
[cx + r * d[0], cy + r * d[1], cz + r * d[2]]
|
||||||
};
|
};
|
||||||
for theta in iso_params(theta_min, theta_span, full, count) {
|
for theta in iso_params(theta_min, theta_span, full, count) {
|
||||||
for m in 0..M {
|
for k in 0..m {
|
||||||
let p0 = phi_min + (phi_max - phi_min) * (m as f64 / M as f64);
|
let p0 = phi_min + (phi_max - phi_min) * (k as f64 / m as f64);
|
||||||
let p1 = phi_min + (phi_max - phi_min) * ((m + 1) as f64 / M as f64);
|
let p1 = phi_min + (phi_max - phi_min) * ((k + 1) as f64 / m as f64);
|
||||||
out.push(sphere_pt(theta, p0));
|
out.push(sphere_pt(theta, p0));
|
||||||
out.push(sphere_pt(theta, p1));
|
out.push(sphere_pt(theta, p1));
|
||||||
}
|
}
|
||||||
|
|
@ -415,11 +467,12 @@ fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec
|
||||||
let phi_total = if full { TAU } else { phi_span };
|
let phi_total = if full { TAU } else { phi_span };
|
||||||
|
|
||||||
// Minor (cross-section) circles — constant revolution angle, full tube.
|
// Minor (cross-section) circles — constant revolution angle, full tube.
|
||||||
const M: usize = 20;
|
// Segment count from the tube (minor) radius at the shared edge tolerance.
|
||||||
|
let m = edge_arc_segs(minor, TAU);
|
||||||
for phi in iso_params(phi_min, phi_span, full, count) {
|
for phi in iso_params(phi_min, phi_span, full, count) {
|
||||||
for t in 0..M {
|
for t in 0..m {
|
||||||
let t0 = TAU * (t as f64 / M as f64);
|
let t0 = TAU * (t as f64 / m as f64);
|
||||||
let t1 = TAU * ((t + 1) 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, t0, phi));
|
||||||
out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t1, phi));
|
out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t1, phi));
|
||||||
}
|
}
|
||||||
|
|
@ -429,7 +482,7 @@ fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec
|
||||||
// revolution arc. The outer (θ=0) and inner (θ=π) circles are the torus's
|
// revolution arc. The outer (θ=0) and inner (θ=π) circles are the torus's
|
||||||
// defining profile — the ring outline; without them it reads as disconnected
|
// defining profile — the ring outline; without them it reads as disconnected
|
||||||
// cross-sections. `count.max(2)` guarantees outer + inner even at ISOLINES=1.
|
// cross-sections. `count.max(2)` guarantees outer + inner even at ISOLINES=1.
|
||||||
let ring_segs = (((phi_total / TAU) * 64.0).ceil() as usize).max(6);
|
let ring_segs = edge_arc_segs(major, phi_total).max(2);
|
||||||
let n_ring = count.max(2);
|
let n_ring = count.max(2);
|
||||||
for k in 0..n_ring {
|
for k in 0..n_ring {
|
||||||
let theta = TAU * (k as f64 / n_ring as f64);
|
let theta = TAU * (k as f64 / n_ring as f64);
|
||||||
|
|
@ -535,7 +588,7 @@ pub(crate) fn torus_phi_range(
|
||||||
let Some(sl) = SatLoop::from_record(lr) else {
|
let Some(sl) = SatLoop::from_record(lr) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let poly = collect_loop_polygon(sat, &sl, 48);
|
let poly = collect_loop_polygon(sat, &sl, BOUNDARY_CHORD_FRAC);
|
||||||
lp = sl.next_loop();
|
lp = sl.next_loop();
|
||||||
if poly.is_empty() {
|
if poly.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -691,7 +744,7 @@ fn collect_curved_gens(
|
||||||
sphere.u_direction().2,
|
sphere.u_direction().2,
|
||||||
]);
|
]);
|
||||||
let v = cross3(pole, u);
|
let v = cross3(pole, u);
|
||||||
let poly = collect_face_polygon(sat, &face, 48);
|
let poly = collect_face_polygon(sat, &face, BOUNDARY_CHORD_FRAC);
|
||||||
let (tmin, tspan, full, pmin, pmax) =
|
let (tmin, tspan, full, pmin, pmax) =
|
||||||
sphere_param_range(&poly, [cx, cy, cz], pole, u, v);
|
sphere_param_range(&poly, [cx, cy, cz], pole, u, v);
|
||||||
let (center, center_low) = place([cx, cy, cz]);
|
let (center, center_low) = place([cx, cy, cz]);
|
||||||
|
|
@ -746,7 +799,6 @@ fn collect_curved_gens(
|
||||||
/// their two vertex endpoints; ellipse/circle edges are sampled along their
|
/// their two vertex endpoints; ellipse/circle edges are sampled along their
|
||||||
/// bounded parametric arc. Points are in body-local space (pre-transform).
|
/// bounded parametric arc. Points are in body-local space (pre-transform).
|
||||||
fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> {
|
fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> {
|
||||||
const EDGE_SEGS: usize = 48;
|
|
||||||
let mut out: Vec<[f64; 3]> = Vec::new();
|
let mut out: Vec<[f64; 3]> = Vec::new();
|
||||||
for er in sat.records_of_type("edge") {
|
for er in sat.records_of_type("edge") {
|
||||||
let Some(edge) = SatEdge::from_record(er) else {
|
let Some(edge) = SatEdge::from_record(er) else {
|
||||||
|
|
@ -761,7 +813,7 @@ fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> {
|
||||||
&ellipse,
|
&ellipse,
|
||||||
edge.start_param(),
|
edge.start_param(),
|
||||||
edge.end_param(),
|
edge.end_param(),
|
||||||
EDGE_SEGS,
|
EDGE_CHORD_FRAC,
|
||||||
reversed,
|
reversed,
|
||||||
);
|
);
|
||||||
// sample_ellipse_arc drops the end param; append the true end so
|
// sample_ellipse_arc drops the end param; append the true end so
|
||||||
|
|
@ -774,7 +826,11 @@ fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> {
|
||||||
// chord the fallback below would draw (or nothing, for a closed
|
// chord the fallback below would draw (or nothing, for a closed
|
||||||
// loop whose endpoints coincide).
|
// loop whose endpoints coincide).
|
||||||
pts = ic
|
pts = ic
|
||||||
.sample_range(edge.start_param(), edge.end_param(), EDGE_SEGS)
|
.sample_range(
|
||||||
|
edge.start_param(),
|
||||||
|
edge.end_param(),
|
||||||
|
nominal_segs(EDGE_CHORD_FRAC),
|
||||||
|
)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(x, y, z)| [x, y, z])
|
.map(|(x, y, z)| [x, y, z])
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -900,15 +956,13 @@ pub(crate) fn apply_body_transform(mesh: &mut MeshModel, m: &[f64; 9], tr: &[f64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scale a LOD's segment counts by FACETRES (clamped to AutoCAD's
|
/// Tighten/loosen a LOD's chord tolerance by FACETRES (clamped to the
|
||||||
/// documented [0.01, 10.0] range). 1.0 is the unchanged baseline.
|
/// documented [0.01, 10.0] range). A higher FACETRES means a finer mesh, so it
|
||||||
|
/// *divides* the chord fraction; 1.0 is the unchanged baseline.
|
||||||
fn scale_lod(base: LodConfig, facet_res: f64) -> LodConfig {
|
fn scale_lod(base: LodConfig, facet_res: f64) -> LodConfig {
|
||||||
let m = (facet_res.clamp(0.01, 10.0) as f32).max(0.01);
|
let m = facet_res.clamp(0.01, 10.0);
|
||||||
let scale = |v: usize| ((v as f32) * m).round().max(4.0) as usize;
|
|
||||||
LodConfig {
|
LodConfig {
|
||||||
circ_segs: scale(base.circ_segs),
|
chord_frac: (base.chord_frac / m).clamp(1e-4, 0.5),
|
||||||
grid_u: scale(base.grid_u),
|
|
||||||
grid_v: scale(base.grid_v),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1032,15 +1086,16 @@ pub fn tessellate_solid3d(
|
||||||
/// Straight edges contribute their start vertex; curved (ellipse / circle)
|
/// Straight edges contribute their start vertex; curved (ellipse / circle)
|
||||||
/// edges are sampled into several points so circular boundaries — e.g. the
|
/// edges are sampled into several points so circular boundaries — e.g. the
|
||||||
/// cap of a cylinder or the rim of a cone — produce a real polygon instead of
|
/// cap of a cylinder or the rim of a cone — produce a real polygon instead of
|
||||||
/// a single degenerate vertex. `circ_segs` is the sample count for a full
|
/// a single degenerate vertex. `chord_frac` is the chord-height tolerance (as a
|
||||||
/// circle (scaled down for shorter arcs).
|
/// fraction of each edge's own radius); the segment count per edge derives from
|
||||||
|
/// it plus that edge's radius and span.
|
||||||
///
|
///
|
||||||
/// Returns an empty `Vec` when the loop topology is broken or has fewer than
|
/// Returns an empty `Vec` when the loop topology is broken or has fewer than
|
||||||
/// three distinct points.
|
/// three distinct points.
|
||||||
pub(crate) fn collect_face_polygon(
|
pub(crate) fn collect_face_polygon(
|
||||||
sat: &SatDocument,
|
sat: &SatDocument,
|
||||||
face: &SatFace,
|
face: &SatFace,
|
||||||
circ_segs: usize,
|
chord_frac: f64,
|
||||||
) -> Vec<[f64; 3]> {
|
) -> Vec<[f64; 3]> {
|
||||||
let Some(loop_rec) = sat.resolve(face.first_loop()) else {
|
let Some(loop_rec) = sat.resolve(face.first_loop()) else {
|
||||||
return vec![];
|
return vec![];
|
||||||
|
|
@ -1048,14 +1103,14 @@ pub(crate) fn collect_face_polygon(
|
||||||
let Some(sat_loop) = SatLoop::from_record(loop_rec) else {
|
let Some(sat_loop) = SatLoop::from_record(loop_rec) else {
|
||||||
return vec![];
|
return vec![];
|
||||||
};
|
};
|
||||||
collect_loop_polygon(sat, &sat_loop, circ_segs)
|
collect_loop_polygon(sat, &sat_loop, chord_frac)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Boundary points of a single coedge loop, in order.
|
/// Boundary points of a single coedge loop, in order.
|
||||||
pub(crate) fn collect_loop_polygon(
|
pub(crate) fn collect_loop_polygon(
|
||||||
sat: &SatDocument,
|
sat: &SatDocument,
|
||||||
sat_loop: &SatLoop,
|
sat_loop: &SatLoop,
|
||||||
circ_segs: usize,
|
chord_frac: f64,
|
||||||
) -> Vec<[f64; 3]> {
|
) -> Vec<[f64; 3]> {
|
||||||
let first_ptr = sat_loop.first_coedge();
|
let first_ptr = sat_loop.first_coedge();
|
||||||
let mut cur = first_ptr;
|
let mut cur = first_ptr;
|
||||||
|
|
@ -1070,7 +1125,7 @@ pub(crate) fn collect_loop_polygon(
|
||||||
|
|
||||||
if let Some(ce_rec) = sat.resolve(cur) {
|
if let Some(ce_rec) = sat.resolve(cur) {
|
||||||
if let Some(coedge) = SatCoedge::from_record(ce_rec) {
|
if let Some(coedge) = SatCoedge::from_record(ce_rec) {
|
||||||
append_coedge_points(sat, &coedge, circ_segs, &mut pts);
|
append_coedge_points(sat, &coedge, chord_frac, &mut pts);
|
||||||
let next = coedge.next();
|
let next = coedge.next();
|
||||||
if next == first_ptr {
|
if next == first_ptr {
|
||||||
break;
|
break;
|
||||||
|
|
@ -1090,7 +1145,7 @@ pub(crate) fn collect_loop_polygon(
|
||||||
pub(crate) fn collect_face_loops(
|
pub(crate) fn collect_face_loops(
|
||||||
sat: &SatDocument,
|
sat: &SatDocument,
|
||||||
face: &SatFace,
|
face: &SatFace,
|
||||||
circ_segs: usize,
|
chord_frac: f64,
|
||||||
) -> Vec<Vec<[f64; 3]>> {
|
) -> Vec<Vec<[f64; 3]>> {
|
||||||
let mut loops: Vec<Vec<[f64; 3]>> = Vec::new();
|
let mut loops: Vec<Vec<[f64; 3]>> = Vec::new();
|
||||||
let mut loop_ptr = face.first_loop();
|
let mut loop_ptr = face.first_loop();
|
||||||
|
|
@ -1102,7 +1157,7 @@ pub(crate) fn collect_face_loops(
|
||||||
let Some(sat_loop) = SatLoop::from_record(loop_rec) else {
|
let Some(sat_loop) = SatLoop::from_record(loop_rec) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let poly = collect_loop_polygon(sat, &sat_loop, circ_segs);
|
let poly = collect_loop_polygon(sat, &sat_loop, chord_frac);
|
||||||
if poly.len() >= 3 {
|
if poly.len() >= 3 {
|
||||||
loops.push(poly);
|
loops.push(poly);
|
||||||
}
|
}
|
||||||
|
|
@ -1118,7 +1173,7 @@ pub(crate) fn collect_face_loops(
|
||||||
pub(crate) fn append_coedge_points(
|
pub(crate) fn append_coedge_points(
|
||||||
sat: &SatDocument,
|
sat: &SatDocument,
|
||||||
coedge: &SatCoedge,
|
coedge: &SatCoedge,
|
||||||
circ_segs: usize,
|
chord_frac: f64,
|
||||||
pts: &mut Vec<[f64; 3]>,
|
pts: &mut Vec<[f64; 3]>,
|
||||||
) {
|
) {
|
||||||
let fwd = matches!(coedge.sense(), Sense::Forward);
|
let fwd = matches!(coedge.sense(), Sense::Forward);
|
||||||
|
|
@ -1138,7 +1193,7 @@ pub(crate) fn append_coedge_points(
|
||||||
&ellipse,
|
&ellipse,
|
||||||
edge.start_param(),
|
edge.start_param(),
|
||||||
edge.end_param(),
|
edge.end_param(),
|
||||||
circ_segs,
|
chord_frac,
|
||||||
reversed,
|
reversed,
|
||||||
);
|
);
|
||||||
if !sampled.is_empty() {
|
if !sampled.is_empty() {
|
||||||
|
|
@ -1154,7 +1209,11 @@ pub(crate) fn append_coedge_points(
|
||||||
// this the face's parametric extent collapses and it can't be trimmed.
|
// this the face's parametric extent collapses and it can't be trimmed.
|
||||||
if let Some(ic) = SatIntCurve::from_record(curve_rec) {
|
if let Some(ic) = SatIntCurve::from_record(curve_rec) {
|
||||||
let mut sampled: Vec<[f64; 3]> = ic
|
let mut sampled: Vec<[f64; 3]> = ic
|
||||||
.sample_range(edge.start_param(), edge.end_param(), circ_segs)
|
.sample_range(
|
||||||
|
edge.start_param(),
|
||||||
|
edge.end_param(),
|
||||||
|
nominal_segs(chord_frac),
|
||||||
|
)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(x, y, z)| [x, y, z])
|
.map(|(x, y, z)| [x, y, z])
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -1189,7 +1248,7 @@ fn sample_ellipse_arc(
|
||||||
ellipse: &SatEllipseCurve,
|
ellipse: &SatEllipseCurve,
|
||||||
sp: f64,
|
sp: f64,
|
||||||
ep: f64,
|
ep: f64,
|
||||||
circ_segs: usize,
|
chord_frac: f64,
|
||||||
curve_reversed: bool,
|
curve_reversed: bool,
|
||||||
) -> Vec<[f64; 3]> {
|
) -> Vec<[f64; 3]> {
|
||||||
let span = ep - sp;
|
let span = ep - sp;
|
||||||
|
|
@ -1220,7 +1279,16 @@ fn sample_ellipse_arc(
|
||||||
// cylinder of the surface radius.
|
// cylinder of the surface radius.
|
||||||
let hand = if curve_reversed { -1.0 } else { 1.0 };
|
let hand = if curve_reversed { -1.0 } else { 1.0 };
|
||||||
|
|
||||||
let segs = (circ_segs as f64 * (span.abs() / TAU)).round().max(2.0) as usize;
|
// Segment count from this ellipse's own radius and arc span at the requested
|
||||||
|
// chord tolerance — the same model the 2-D circle/arc/ellipse wires use, so
|
||||||
|
// a big rim samples finer than a small one and a short arc proportionally
|
||||||
|
// less than a full turn. `major_len` is the reference radius.
|
||||||
|
let segs = crate::scene::convert::tess_util::arc_segments_floored(
|
||||||
|
major_len,
|
||||||
|
span.abs(),
|
||||||
|
major_len * chord_frac,
|
||||||
|
2,
|
||||||
|
) as usize;
|
||||||
let mut out = Vec::with_capacity(segs);
|
let mut out = Vec::with_capacity(segs);
|
||||||
for i in 0..segs {
|
for i in 0..segs {
|
||||||
let t = sp + span * (i as f64 / segs as f64);
|
let t = sp + span * (i as f64 / segs as f64);
|
||||||
|
|
@ -1271,12 +1339,12 @@ pub(crate) fn tess_plane_face(
|
||||||
sat: &SatDocument,
|
sat: &SatDocument,
|
||||||
face: &SatFace,
|
face: &SatFace,
|
||||||
plane: &SatPlaneSurface,
|
plane: &SatPlaneSurface,
|
||||||
circ_segs: usize,
|
chord_frac: f64,
|
||||||
verts: &mut Vec<[f32; 3]>,
|
verts: &mut Vec<[f32; 3]>,
|
||||||
normals: &mut Vec<[f32; 3]>,
|
normals: &mut Vec<[f32; 3]>,
|
||||||
indices: &mut Vec<u32>,
|
indices: &mut Vec<u32>,
|
||||||
) {
|
) {
|
||||||
let mut poly = collect_face_polygon(sat, face, circ_segs);
|
let mut poly = collect_face_polygon(sat, face, chord_frac);
|
||||||
if poly.len() < 3 {
|
if poly.len() < 3 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1320,7 +1388,7 @@ pub(crate) fn tess_cone_face(
|
||||||
indices: &mut Vec<u32>,
|
indices: &mut Vec<u32>,
|
||||||
) {
|
) {
|
||||||
// Determine the height range and angular span from the boundary polygon.
|
// Determine the height range and angular span from the boundary polygon.
|
||||||
let poly = collect_face_polygon(sat, face, lod.circ_segs);
|
let poly = collect_face_polygon(sat, face, lod.chord_frac);
|
||||||
|
|
||||||
let (cx, cy, cz) = cone.center();
|
let (cx, cy, cz) = cone.center();
|
||||||
let (ax, ay, az) = cone.axis(); // axis direction (unit)
|
let (ax, ay, az) = cone.axis(); // axis direction (unit)
|
||||||
|
|
@ -1367,11 +1435,20 @@ pub(crate) fn tess_cone_face(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Angular divisions scale with the arc's share of a full circle, so a short
|
// Angular divisions from the rim radius and arc span at the LOD's chord
|
||||||
// boundary arc (a curved wall face) isn't tessellated as finely as a whole
|
// tolerance — a short boundary arc (a curved wall face) samples proportionally
|
||||||
// rim — matching the wire arc sampler and keeping the triangle budget sane.
|
// less than a whole rim. Use the wider rim so the density bounds chord error
|
||||||
let segs_u = ((lod.circ_segs as f64 * theta_span / TAU).round() as usize).max(1);
|
// at both ends. The height direction is a straight generator (a cone/cylinder
|
||||||
let segs_v = (lod.circ_segs / 4).max(1); // height subdivisions
|
// is ruled), so it carries no curvature: one division is geometrically exact.
|
||||||
|
let r_ref = if cos_a.abs() > 1e-9 {
|
||||||
|
(radius + h_min * sin_a / cos_a)
|
||||||
|
.abs()
|
||||||
|
.max((radius + h_max * sin_a / cos_a).abs())
|
||||||
|
} else {
|
||||||
|
radius.abs()
|
||||||
|
};
|
||||||
|
let segs_u = lod.arc_segs(r_ref, theta_span).max(1);
|
||||||
|
let segs_v = 1; // straight generator — no curvature along the height
|
||||||
|
|
||||||
for j in 0..segs_v {
|
for j in 0..segs_v {
|
||||||
let t0 = h_min + h_span * (j as f64 / segs_v as f64);
|
let t0 = h_min + h_span * (j as f64 / segs_v as f64);
|
||||||
|
|
@ -1593,13 +1670,10 @@ pub(crate) fn tess_sphere_face(
|
||||||
let u_dir = norm3([ux, uy, uz]);
|
let u_dir = norm3([ux, uy, uz]);
|
||||||
let v_dir = cross3(pole, u_dir);
|
let v_dir = cross3(pole, u_dir);
|
||||||
|
|
||||||
let nu = lod.grid_u.max(3);
|
|
||||||
let nv = lod.grid_v.max(2);
|
|
||||||
|
|
||||||
// Mesh only the part of the sphere the face covers — its boundary loop's
|
// Mesh only the part of the sphere the face covers — its boundary loop's
|
||||||
// longitude/colatitude window. A partial sphere (a fillet cap) otherwise
|
// longitude/colatitude window. A partial sphere (a fillet cap) otherwise
|
||||||
// builds as a full ball floating where the solid is open.
|
// builds as a full ball floating where the solid is open.
|
||||||
let poly = collect_face_polygon(sat, face, nu.max(24));
|
let poly = collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC);
|
||||||
let (t_min, t_span, full, p_min, p_max) =
|
let (t_min, t_span, full, p_min, p_max) =
|
||||||
sphere_param_range(&poly, [cx, cy, cz], pole, u_dir, v_dir);
|
sphere_param_range(&poly, [cx, cy, cz], pole, u_dir, v_dir);
|
||||||
let (theta_lo, theta_hi) = if full {
|
let (theta_lo, theta_hi) = if full {
|
||||||
|
|
@ -1613,6 +1687,12 @@ pub(crate) fn tess_sphere_face(
|
||||||
(p_min, p_max)
|
(p_min, p_max)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Longitude / colatitude divisions from the sphere radius and the covered
|
||||||
|
// spans at the LOD's chord tolerance — a small cap samples far fewer than a
|
||||||
|
// full ball. Every circle of latitude is at most radius `r` (the equator).
|
||||||
|
let nu = lod.arc_segs(r, theta_hi - theta_lo).max(1);
|
||||||
|
let nv = lod.arc_segs(r, phi_hi - phi_lo).max(1);
|
||||||
|
|
||||||
for j in 0..nv {
|
for j in 0..nv {
|
||||||
let phi0 = phi_lo + (phi_hi - phi_lo) * (j as f64 / nv as f64);
|
let phi0 = phi_lo + (phi_hi - phi_lo) * (j as f64 / nv as f64);
|
||||||
let phi1 = phi_lo + (phi_hi - phi_lo) * ((j + 1) as f64 / nv as f64);
|
let phi1 = phi_lo + (phi_hi - phi_lo) * ((j + 1) as f64 / nv as f64);
|
||||||
|
|
@ -1679,14 +1759,17 @@ pub(crate) fn tess_torus_face(
|
||||||
let major_r = torus.major_radius();
|
let major_r = torus.major_radius();
|
||||||
let minor_r = torus.minor_radius();
|
let minor_r = torus.minor_radius();
|
||||||
|
|
||||||
let nu = lod.grid_u.max(3); // around the tube
|
// Tube cross-section is a closed minor circle → full-circle segment count at
|
||||||
|
// the tube (minor) radius.
|
||||||
|
let nu = lod.circle_segs(minor_r).max(3); // around the tube
|
||||||
|
|
||||||
// Mesh only the revolution arc the face covers. A partial tube (an open "C")
|
// Mesh only the revolution arc the face covers. A partial tube (an open "C")
|
||||||
// otherwise builds as a full closed ring where the solid is open.
|
// otherwise builds as a full closed ring where the solid is open.
|
||||||
let (phi_start, phi_arc, full) = torus_phi_range(sat, face, [cx, cy, cz], u_dir, v_dir);
|
let (phi_start, phi_arc, full) = torus_phi_range(sat, face, [cx, cy, cz], u_dir, v_dir);
|
||||||
let phi_total = if full { TAU } else { phi_arc };
|
let phi_total = if full { TAU } else { phi_arc };
|
||||||
// Keep the along-length facet density independent of the arc length.
|
// Along-length divisions from the ring (major) radius and the covered arc at
|
||||||
let nv = (((phi_total / TAU) * lod.grid_v as f64).ceil() as usize).max(3);
|
// the LOD's chord tolerance — a short arc samples proportionally less.
|
||||||
|
let nv = lod.arc_segs(major_r, phi_total).max(2);
|
||||||
|
|
||||||
for j in 0..nv {
|
for j in 0..nv {
|
||||||
let phi0 = phi_start + phi_total * (j as f64 / nv as f64);
|
let phi0 = phi_start + phi_total * (j as f64 / nv as f64);
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,11 @@ pub fn tess_spline_face(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let su = lod.grid_u.max(8);
|
// A B-spline patch has no single analytic radius to drive a chord-tolerance
|
||||||
let sv = lod.grid_v.max(8);
|
// count, so sample at the LOD's nominal density (its unit-circle segment
|
||||||
|
// count). Floor 8 so a curved patch stays smooth.
|
||||||
|
let n = crate::scene::convert::solid3d_tess::nominal_segs(lod.chord_frac).max(8);
|
||||||
|
let (su, sv) = (n, n);
|
||||||
|
|
||||||
let base = verts.len() as u32;
|
let base = verts.len() as u32;
|
||||||
for j in 0..=sv {
|
for j in 0..=sv {
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,11 @@ pub type FallbackGeometry = (
|
||||||
/// must therefore store mirrored angles, not geometric ones.
|
/// must therefore store mirrored angles, not geometric ones.
|
||||||
pub fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) {
|
pub fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) {
|
||||||
const TAU: f64 = std::f64::consts::TAU;
|
const TAU: f64 = std::f64::consts::TAU;
|
||||||
let (sa, ea) = if ccw { (start, end) } else { (TAU - start, TAU - end) };
|
let (sa, ea) = if ccw {
|
||||||
|
(start, end)
|
||||||
|
} else {
|
||||||
|
(TAU - start, TAU - end)
|
||||||
|
};
|
||||||
(sa, ea - sa)
|
(sa, ea - sa)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,13 +67,21 @@ pub fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) {
|
||||||
/// from the per-frame override set by `Scene::wires_for_block` so
|
/// from the per-frame override set by `Scene::wires_for_block` so
|
||||||
/// far-out arcs collapse to a handful of segments.
|
/// far-out arcs collapse to a handful of segments.
|
||||||
pub fn arc_segments(radius: f64, span_abs: f64, chord_tol_world: f64) -> u32 {
|
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 {
|
if span_abs < 1e-9 || radius < 1e-9 {
|
||||||
return 1;
|
return floor.max(1);
|
||||||
}
|
}
|
||||||
let tol = chord_tol_world.max(1e-9).min(radius * 0.99);
|
let tol = chord_tol_world.max(1e-9).min(radius * 0.99);
|
||||||
// θ where r * (1 - cos(θ/2)) = tol → θ = 2 * acos(1 - tol/r).
|
// θ where r * (1 - cos(θ/2)) = tol → θ = 2 * acos(1 - tol/r).
|
||||||
let max_step = (2.0 * (1.0 - tol / radius).acos()).max(1e-6);
|
let max_step = (2.0 * (1.0 - tol / radius).acos()).max(1e-6);
|
||||||
((span_abs / max_step).ceil() as u32).clamp(8, 512)
|
((span_abs / max_step).ceil() as u32).clamp(floor, 512)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Chord tolerance for the load-time fill polygon: 0.1% of radius,
|
/// Chord tolerance for the load-time fill polygon: 0.1% of radius,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue