feat: plinegen + SAB parsing for Solid3D/Region/Body

LwPolyline PLINEGEN=0 (DXF default): emit SegmentedLines with NaN
separators between vertex-to-vertex segments; wire_gpu resets
arc-length to 0 after each NaN so linetype pattern restarts per vertex.
PLINEGEN=1: existing Contour path unchanged (continuous pattern).
WireModel gains plinegen field propagated through tessellate pipeline.

Solid3D/Region/Body: call SabReader::read() when parse_sat() returns
None due to binary SAB format (DWG files / newer AutoCAD exports).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Sonnet 4.6 committed 2026-05-06 06:55:28 +03:00
1 parent 63b17547f9
commit 27aca41dd1
39 files changed
+168 -15

No files matched your search

+6 -6
View File
@@ -32,7 +32,7 @@ Legend: ✅ Done · ⚠️ Partial · ❌ Not done
| Status | Entity | Ignored Field(s) | Effect |
|---|---|---|---|
| ✅ | **Arc / Circle / Line / LwPolyline / Polyline2D** | `thickness` | Extrudes along WCS normal — `src/entities/{arc,circle,line,lwpolyline,polyline}.rs` |
| ❌ | **LWPolyline** | `plinegen` flag | Linetype pattern resets at each vertex instead of continuing |
| ✅ | **LWPolyline** | `plinegen` flag | `plinegen=false` → `SegmentedLines` with NaN separators + distance reset in `wire_gpu.rs`; `plinegen=true` → `Contour` (continuous) |
---
@@ -49,7 +49,7 @@ Legend: ✅ Done · ⚠️ Partial · ❌ Not done
| Status | Entity | Missing |
|---|---|---|
| ⚠️ | **Solid3D / Region / Body** | `src/scene/solid3d_tess.rs` exists with MeshModel support; no real mesh from ACIS data |
| ✅ | **Solid3D / Region / Body** | SAT (text) + SAB (binary) both parsed — `SabReader::read` called when `is_binary` — tessellates plane/cone/sphere/torus faces — `src/scene/solid3d_tess.rs` |
### Partial Render
@@ -207,13 +207,13 @@ These are fixed in our post-load `fix_dxf_dimension_rotations()` in `src/io/mod.
| Status | Count |
|---|---|
| ✅ Done | 48 |
| ⚠️ Partial | 2 |
| ✅ Done | 50 |
| ⚠️ Partial | 1 |
| ❌ Not done | 0 |
| **Total** | **50** |
| **Total** | **51** |
### Remaining gaps by priority
**Medium:** Viewport GPU scissor rect (pixel-level boundary clipping for overlapping viewports)
**Low:** LWPolyline plinegen — GPU shader change needed; current behavior is continuous arc-length across vertices (equivalent to plinegen=true). Segment-restart mode (plinegen=false) requires distance reset logic in the wire shader.
No remaining low-priority gaps.
+1
View File
@@ -1366,6 +1366,7 @@ impl H7CAD {
tangent_geoms: vec![],
key_vertices: vec![],
aabb: crate::scene::WireModel::UNBOUNDED_AABB,
plinegen: true,
};
previews.push(guide);
}
+56
View File
@@ -184,6 +184,62 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity {
return thick_segments(&seg_data, &path, pline.thickness, normal, kv, tgs);
}
// plinegen=false: NaN-separated segments so the linetype pattern restarts per vertex.
if !pline.plinegen {
let mut pts: Vec<[f32; 3]> = Vec::new();
let mut tgs: Vec<TangentGeom> = Vec::new();
let mut kv: Vec<[f32; 3]> = Vec::new();
for i in 0..seg_count {
let va = &verts[i];
let vb = &verts[(i + 1) % count];
let (ox0, oy0) = (va.location.x, va.location.y);
let (ox1, oy1) = (vb.location.x, vb.location.y);
let bulge = va.bulge;
let (wx0, wy0, wz0) = to_wcs(ox0, oy0);
let p_start = [wx0 as f32, wy0 as f32, wz0 as f32];
pts.push(p_start);
if i == 0 { kv.push(p_start); }
if bulge.abs() < 1e-9 {
let (wx1, wy1, wz1) = to_wcs(ox1, oy1);
let p_end = [wx1 as f32, wy1 as f32, wz1 as f32];
pts.push(p_end);
kv.push(p_end);
tgs.push(TangentGeom::Line { p1: p_start, p2: p_end });
} else {
let angle = 4.0 * bulge.atan();
let dx = ox1 - ox0; let dy = oy1 - oy0;
let d = (dx * dx + dy * dy).sqrt().max(1e-12);
let r = (d / 2.0) / (angle / 2.0).sin().abs();
let mx = (ox0 + ox1) * 0.5; let my = (oy0 + oy1) * 0.5;
let px = -dy / d; let py = dx / d;
let ss = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 };
let h = r - (r * r - d * d / 4.0).max(0.0).sqrt();
let ocx = mx + ss * px * (r - h); let ocy = my + ss * py * (r - h);
let a0 = (oy0 - ocy).atan2(ox0 - ocx);
let mut a1 = (oy1 - ocy).atan2(ox1 - ocx);
if bulge > 0.0 { if a1 < a0 { a1 += TAU; } } else { if a1 > a0 { a1 -= TAU; } }
for j in 1..=16usize {
let a = a0 + (a1 - a0) * (j as f64 / 16.0);
let (wx, wy, wz) = to_wcs(ocx + r * a.cos(), ocy + r * a.sin());
pts.push([wx as f32, wy as f32, wz as f32]);
}
let (wx1, wy1, wz1) = to_wcs(ox1, oy1);
kv.push([wx1 as f32, wy1 as f32, wz1 as f32]);
let (wcx, wcy, wcz) = to_wcs(ocx, ocy);
tgs.push(TangentGeom::Circle { center: [wcx as f32, wcy as f32, wcz as f32], radius: r as f32 });
}
if i + 1 < seg_count {
pts.push([f32::NAN; 3]);
}
}
return TruckEntity {
object: TruckObject::SegmentedLines(pts),
snap_pts: vec![],
tangent_geoms: tgs,
key_vertices: kv,
};
}
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % count];
+2
View File
@@ -102,6 +102,7 @@ impl CadCommand for AlignedDimensionCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
@@ -136,5 +137,6 @@ fn preview_aligned(p1: Vec3, p2: Vec3, dim_pt: Vec3) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -125,6 +125,7 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -101,5 +101,6 @@ fn preview_line(a: Vec3, b: Vec3) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -143,6 +143,7 @@ impl CadCommand for DimBaselineCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -140,6 +140,7 @@ impl CadCommand for DimContinueCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -94,6 +94,7 @@ impl CadCommand for DimJogLineCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -102,6 +102,7 @@ impl CadCommand for DimTeditCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
+1
View File
@@ -202,6 +202,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -113,6 +113,7 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -155,6 +155,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -366,6 +366,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -99,6 +99,7 @@ impl CadCommand for QdimCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -112,5 +112,6 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -145,6 +145,7 @@ impl CadCommand for TableCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
} else {
None
+1
View File
@@ -98,6 +98,7 @@ impl CadCommand for ToleranceCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -134,6 +134,7 @@ impl CadCommand for AttdefCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -527,5 +527,6 @@ fn line_wire(from: Vec3, to: Vec3) -> WireModel {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
+1
View File
@@ -88,6 +88,7 @@ impl CadCommand for LineCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -113,6 +113,7 @@ impl CadCommand for MlineCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -112,6 +112,7 @@ impl CadCommand for ImageCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+2
View File
@@ -86,6 +86,7 @@ impl CadCommand for RayCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
@@ -164,6 +165,7 @@ impl CadCommand for XLineCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -83,6 +83,7 @@ impl CadCommand for RevCloudCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+2
View File
@@ -119,6 +119,7 @@ impl CadCommand for WipeoutCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
WipeoutMode::Polygonal => {
@@ -140,6 +141,7 @@ impl CadCommand for WipeoutCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -76,6 +76,7 @@ impl CadCommand for AreaCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -71,6 +71,7 @@ impl CadCommand for DistCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -134,6 +134,7 @@ impl CadCommand for StretchCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
_ => None,
+1
View File
@@ -98,6 +98,7 @@ impl CadCommand for MviewCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -63,6 +63,7 @@ impl CadCommand for PlotWindowCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+1
View File
@@ -69,6 +69,7 @@ impl CadCommand for ZoomWindowCommand {
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
}
}
+2
View File
@@ -23,6 +23,8 @@ pub enum TruckObject {
Text(Vec<TextStroke>),
/// Pre-computed NaN-separated 3-D point list (leader lines, arrowheads, etc.).
Lines(Vec<[f32; 3]>),
/// Like Lines but linetype pattern restarts at each NaN-separated segment (plinegen=false).
SegmentedLines(Vec<[f32; 3]>),
Volume(Solid),
}
+1
View File
@@ -202,6 +202,7 @@ pub fn apply_along(
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
})
.collect()
}
+1
View File
@@ -3211,6 +3211,7 @@ fn tessellate_entity(
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
});
return wires;
}
+12 -4
View File
@@ -148,7 +148,12 @@ impl WireGpu {
let p = wire.points[i - 1];
let q = wire.points[i];
if !p[0].is_finite() || !q[0].is_finite() {
dists[i] = dists[i - 1];
// plinegen=false: reset to 0 at the first real point after a NaN separator.
dists[i] = if !wire.plinegen && !p[0].is_finite() && q[0].is_finite() {
0.0
} else {
dists[i - 1]
};
} else {
let dx = q[0] - p[0];
let dy = q[1] - p[1];
@@ -234,10 +239,13 @@ impl WireGpu {
for i in 1..n {
let p = wire.points[i - 1];
let q = wire.points[i];
// If either point is non-finite (NaN sentinel or ±inf from overflow),
// keep the same distance — the segment will be skipped anyway.
if !p[0].is_finite() || !q[0].is_finite() {
dists[i] = dists[i - 1];
// plinegen=false: reset to 0 at the first real point after a NaN separator.
dists[i] = if !wire.plinegen && !p[0].is_finite() && q[0].is_finite() {
0.0
} else {
dists[i - 1]
};
} else {
let dx = q[0] - p[0];
let dy = q[1] - p[1];
+15 -5
View File
@@ -14,8 +14,8 @@ use std::collections::HashSet;
use std::f64::consts::TAU;
use acadrust::entities::acis::{
SatCoedge, SatConeSurface, SatDocument, SatEdge, SatFace, SatLoop, SatPlaneSurface, SatPoint,
SatPointer, SatSphereSurface, SatTorusSurface, SatVertex,
SabReader, SatCoedge, SatConeSurface, SatDocument, SatEdge, SatFace, SatLoop,
SatPlaneSurface, SatPoint, SatPointer, SatSphereSurface, SatTorusSurface, SatVertex,
};
use acadrust::entities::acis::types::Sense;
use acadrust::entities::{Body, Region, Solid3D};
@@ -67,16 +67,26 @@ fn tessellate_sat(sat: &SatDocument, name: String, color: [f32; 4]) -> Option<Me
Some(MeshModel { name, verts, normals, indices, color, selected: false })
}
fn parse_acis(sat_fn: impl FnOnce() -> Option<SatDocument>, is_binary: bool, sab_data: &[u8]) -> Option<SatDocument> {
if let Some(doc) = sat_fn() {
return Some(doc);
}
if is_binary && !sab_data.is_empty() {
return SabReader::read(sab_data).ok();
}
None
}
/// Tessellate a `Region` entity (2D planar ACIS body) into a `MeshModel`.
pub fn tessellate_region(region: &Region, color: [f32; 4]) -> Option<MeshModel> {
let sat = region.parse_sat()?;
let sat = parse_acis(|| region.parse_sat(), region.acis_data.is_binary, &region.acis_data.sab_data)?;
let name = region.common.handle.value().to_string();
tessellate_sat(&sat, name, color)
}
/// Tessellate a `Body` entity (3D ACIS body) into a `MeshModel`.
pub fn tessellate_body(body: &Body, color: [f32; 4]) -> Option<MeshModel> {
let sat = body.parse_sat()?;
let sat = parse_acis(|| body.parse_sat(), body.acis_data.is_binary, &body.acis_data.sab_data)?;
let name = body.common.handle.value().to_string();
tessellate_sat(&sat, name, color)
}
@@ -86,7 +96,7 @@ pub fn tessellate_body(body: &Body, color: [f32; 4]) -> Option<MeshModel> {
/// Returns `None` when the entity has no parseable SAT data or produces no
/// triangles (e.g. the solid uses only unsupported surface types).
pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4]) -> Option<MeshModel> {
let sat = solid.parse_sat()?;
let sat = parse_acis(|| solid.parse_sat(), solid.acis_data.is_binary, &solid.acis_data.sab_data)?;
let name = solid.common.handle.value().to_string();
tessellate_sat(&sat, name, color)
}
+37
View File
@@ -104,6 +104,7 @@ pub fn tessellate(
aci: 0,
key_vertices: te.key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
};
}
@@ -132,6 +133,7 @@ pub fn tessellate(
aci: 0,
key_vertices: te.key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
};
}
_ => {}
@@ -158,6 +160,7 @@ pub fn tessellate(
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
};
}
}
@@ -182,6 +185,7 @@ pub fn tessellate(
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
};
}
}
@@ -212,6 +216,34 @@ pub fn tessellate(
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
};
}
TruckObject::SegmentedLines(points) => {
let [ox, oy, oz] = world_offset;
let local_pts: Vec<[f32; 3]> = points.into_iter().map(|[x, y, z]| {
if x.is_nan() { [x, y, z] }
else { [x - ox as f32, y - oy as f32, z - oz as f32] }
}).collect();
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
let key_vertices: Vec<[f32; 3]> = te.key_vertices.into_iter()
.map(|[x, y, z]| [x - ox as f32, y - oy as f32, z - oz as f32])
.collect();
return WireModel {
name,
points: local_pts,
color,
selected,
pattern_length,
pattern,
line_weight_px,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
plinegen: false,
aabb: WireModel::UNBOUNDED_AABB,
};
}
@@ -241,6 +273,7 @@ pub fn tessellate(
tangent_geoms,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}
}
@@ -296,6 +329,7 @@ pub fn tessellate_dimension(
tangent_geoms: vec![],
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}];
if let Some(text) = dimension_text_entity(dim) {
@@ -344,6 +378,7 @@ fn tessellate_leader(
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}];
}
@@ -412,6 +447,7 @@ fn tessellate_leader(
tangent_geoms: vec![],
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}]
}
@@ -521,6 +557,7 @@ fn tessellate_multileader(
aci: 0,
key_vertices: key_verts,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
}];
// Render text content as MText wire
+5
View File
@@ -60,6 +60,9 @@ pub struct WireModel {
/// Preview / interim wires use `UNBOUNDED_AABB` so they are never pre-rejected
/// by the snap world-space filter.
pub aabb: [f32; 4],
/// When false the linetype pattern restarts at each NaN-separated segment
/// (DXF PLINEGEN=0). When true the pattern runs continuously (PLINEGEN=1).
pub plinegen: bool,
}
impl WireModel {
@@ -85,6 +88,7 @@ impl WireModel {
tangent_geoms: vec![],
key_vertices: vec![],
aabb: Self::UNBOUNDED_AABB,
plinegen: true,
}
}
@@ -184,6 +188,7 @@ impl Default for WireModel {
tangent_geoms: Vec::new(),
key_vertices: Vec::new(),
aabb: Self::UNBOUNDED_AABB,
plinegen: true,
}
}
}