feat(polyline): smooth per-segment taper for varying-width wide polylines

A wide polyline with per-vertex varying widths rendered at its widest edge
(uniform-width ones were already exact). Draw the true taper instead.

`WireModel.taper_widths` carries a per-point full band width. The wire
instance (native slim + web fat) gains per-endpoint `world_hw_a/b`, and both
wire shaders interpolate `mix(hw_a, hw_b, which_end)` so each segment's band
narrows/widens linearly — depth-tested, and the linetype dash still applies.

Alignment (widths must match the tessellated points) is solved by building
both together: a new `TruckObject::TaperedLines(points, widths)`, filled by
`common::tapered_band_points` (straight = endpoints, arc = 16 samples with the
width lerped along it). to_truck routes a varying-width polyline here —
LwPolyline before the PLINEGEN split, Polyline2D at its return — while a
uniform-width one keeps the constant-band Contour path unchanged. `world_width`
stays the widest edge as the PDF-stroke + zoom-floor fallback.

Blocks fall back to the constant-widest band (`LocalWire` carries no
taper_widths).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-20 16:36:06 +03:00
1 parent a8aece1909
commit b3016ca892
48 files changed
+322 -7

No files matched your search

+41
View File
@@ -392,6 +392,47 @@ 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.
pub(crate) fn tapered_band_points(
verts: &[([f64; 2], f64, f64, f64)],
is_closed: bool,
to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64),
) -> (Vec<[f64; 3]>, Vec<f32>) {
let n = verts.len();
let seg_count = if is_closed { n } else { n.saturating_sub(1) };
let mut pts: Vec<[f64; 3]> = Vec::new();
let mut widths: Vec<f32> = Vec::new();
let mut push = |x: f64, y: f64, w: f32| {
let (wx, wy, wz) = to_wcs(x, y);
pts.push([wx, wy, wz]);
widths.push(w);
};
for i in 0..seg_count {
let (p0, bulge, sw0, ew0) = verts[i];
let (p1, _, _, _) = verts[(i + 1) % n];
if i == 0 {
push(p0[0], p0[1], sw0 as f32);
}
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);
push(s[0], s[1], (sw0 + (ew0 - sw0) * t) as f32);
}
}
}
(pts, widths)
}
/// Compute the filled boundary polygon for one polyline segment.
/// For straight segments: a rectangle/trapezoid.
/// For arc segments: an arc band (outer arc + reversed inner arc).
+5
View File
@@ -1352,6 +1352,7 @@ fn tessellate_dimension_inner(
let (ext1, ext2) = split_ext_lines(&geom.ext_lines);
if !ext1.is_empty() {
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
@@ -1379,6 +1380,7 @@ fn tessellate_dimension_inner(
}
if !ext2.is_empty() {
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
@@ -1406,6 +1408,7 @@ fn tessellate_dimension_inner(
}
} else {
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
@@ -1434,6 +1437,7 @@ fn tessellate_dimension_inner(
}
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
@@ -1478,6 +1482,7 @@ fn tessellate_dimension_inner(
aci_to_rgba(&c)
};
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
+2
View File
@@ -590,6 +590,7 @@ impl LeaderTess for Leader {
if verts.len() < 2 {
return WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
@@ -697,6 +698,7 @@ impl LeaderTess for Leader {
}
WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
+71
View File
@@ -213,6 +213,51 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity {
return thick_segments(&seg_data, &path, pline.thickness, normal, kv, tgs);
}
// A wide polyline whose per-vertex widths VARY renders a smooth taper —
// handled here, before the PLINEGEN split, so both cases get it. A
// uniform-width polyline falls through to the constant-band paths below.
if let Some(band_verts) = tapered_band_verts(pline) {
let mut kv: Vec<[f64; 3]> = Vec::new();
let mut tgs: Vec<TangentGeom> = Vec::new();
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % count];
let p0 = to_pt(v0);
let p1 = to_pt(v1);
if i == 0 {
kv.push([p0.x, p0.y, p0.z]);
}
kv.push([p1.x, p1.y, p1.z]);
if v0.bulge.abs() < 1e-9 {
tgs.push(TangentGeom::Line {
p1: [p0.x as f32, p0.y as f32, p0.z as f32],
p2: [p1.x as f32, p1.y as f32, p1.z as f32],
});
} else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge(
[v0.location.x, v0.location.y],
[v1.location.x, v1.location.y],
v0.bulge,
) {
let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]);
tgs.push(TangentGeom::Circle {
center: [wcx as f32, wcy as f32, wcz as f32],
radius: arc.radius as f32,
});
}
}
let (pts, widths) =
crate::entities::common::tapered_band_points(&band_verts, pline.is_closed, &to_wcs);
let (fill_origin, fills) = wide_fills(pline);
return TruckEntity {
pick_tris: crate::entities::common::wide_band_tris(fill_origin, &fills),
object: TruckObject::TaperedLines(pts, widths),
snap_pts: vec![],
tangent_geoms: tgs,
key_vertices: kv,
fill_tris: vec![],
};
}
// plinegen=false: NaN-separated segments so the linetype pattern restarts per vertex.
if !pline.plinegen {
let mut pts: Vec<[f64; 3]> = Vec::new();
@@ -321,6 +366,32 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity {
}
}
/// The per-vertex `(location, bulge, start_width, end_width)` band description
/// for a wide LwPolyline whose width VARIES — `None` when the width is uniform
/// (a constant band) so the caller keeps the cheaper constant-width path.
fn tapered_band_verts(pline: &LwPolyline) -> Option<Vec<([f64; 2], f64, f64, f64)>> {
let c = pline.constant_width;
let band: Vec<([f64; 2], f64, f64, f64)> = pline
.vertices
.iter()
.map(|v| {
let sw = if v.start_width > 1e-9 { v.start_width } else { c };
let ew = if v.end_width > 1e-9 { v.end_width } else { c };
([v.location.x, v.location.y], v.bulge, sw, ew)
})
.collect();
let w0 = band.first().map_or(0.0, |v| v.2);
let varies = band
.iter()
.any(|&(_, _, sw, ew)| (sw - w0).abs() > 1e-9 || (ew - w0).abs() > 1e-9);
// Only a genuine, non-zero-width taper takes the per-point path.
if varies && w0.max(band.iter().map(|v| v.3).fold(0.0, f64::max)) > 1e-9 {
Some(band)
} else {
None
}
}
fn grips(pline: &LwPolyline) -> Vec<GripDef> {
let elev = pline.elevation;
let n = pline.vertices.len();
+4
View File
@@ -1404,6 +1404,7 @@ impl MultiLeaderTess for MultiLeader {
// WireModels so the renderer respects per-piece coloring.
let mut wires: Vec<WireModel> = Vec::new();
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
@@ -1694,6 +1695,7 @@ impl MultiLeaderTess for MultiLeader {
xy = xy.max(y);
}
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
@@ -1762,6 +1764,7 @@ impl MultiLeaderTess for MultiLeader {
wcs_corners[3],
];
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
@@ -1798,6 +1801,7 @@ impl MultiLeaderTess for MultiLeader {
wcs_corners[0],
];
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),
+40 -1
View File
@@ -326,9 +326,22 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> TruckEntity {
}
let (fill_origin, fills) = wide_fills(pl);
// A wide Polyline2D whose per-vertex widths VARY renders a smooth taper; a
// uniform-width one keeps the constant-band Contour.
let object = match tapered_band_verts_2d(pl) {
Some(band_verts) => {
let (pts, widths) = crate::entities::common::tapered_band_points(
&band_verts,
pl.is_closed(),
&to_wcs,
);
TruckObject::TaperedLines(pts, widths)
}
None => TruckObject::Contour(edges.into_iter().collect::<Wire>()),
};
TruckEntity {
pick_tris: crate::entities::common::wide_band_tris(fill_origin, &fills),
object: TruckObject::Contour(edges.into_iter().collect::<Wire>()),
object,
snap_pts: vec![],
tangent_geoms: tangents,
key_vertices: key_verts,
@@ -336,6 +349,32 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> TruckEntity {
}
}
/// Per-vertex `(location, bulge, start_width, end_width)` band description for a
/// wide Polyline2D whose width VARIES — `None` when the width is uniform.
fn tapered_band_verts_2d(
pl: &acadrust::entities::Polyline2D,
) -> Option<Vec<([f64; 2], f64, f64, f64)>> {
let d = pl.start_width.max(pl.end_width);
let band: Vec<([f64; 2], f64, f64, f64)> = pl
.vertices
.iter()
.map(|v| {
let sw = if v.start_width > 1e-9 { v.start_width } else { d };
let ew = if v.end_width > 1e-9 { v.end_width } else { d };
([v.location.x, v.location.y], v.bulge, sw, ew)
})
.collect();
let w0 = band.first().map_or(0.0, |v| v.2);
let varies = band
.iter()
.any(|&(_, _, sw, ew)| (sw - w0).abs() > 1e-9 || (ew - w0).abs() > 1e-9);
if varies && w0.max(band.iter().map(|v| v.3).fold(0.0, f64::max)) > 1e-9 {
Some(band)
} else {
None
}
}
impl TruckConvertible for Polyline2D {
fn to_truck(&self, _document: &acadrust::CadDocument) -> Option<TruckEntity> {
Some(tessellate_polyline2d(self))
+1
View File
@@ -753,6 +753,7 @@ pub fn tessellate_table(
let mk =
|color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel {
WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
fill_is_3d: false,
pick_tris: Vec::new(),