feat(shape): render SHAPE entities from their compiled SHX shape file

SHAPE entities only drew a diamond placeholder — .SHX files were never
parsed. A new reader (scene/text/shx.rs) decodes the classic
`AutoCAD-86 shapes 1.0/1.1` container and interprets the full shape
bytecode — pen up/down, scale divide/multiply, position push/pop,
subshapes, signed XY runs, octant/fractional/bulge arcs, the 16
direction vectors and the vertical-only skip — into unit-space
polylines, cached per file and per shape number.

The entity resolves its STYLE by handle (name fallback), the style's
shape file with the shared path fallbacks (as stored → relative to the
drawing → basename next to it; traded files carry foreign absolute
paths), then places the glyph with size × relative X scale, oblique
shear and rotation through the OCS mapping. load_file now records
doc.source_path so render-time resolution knows the drawing's folder.
A missing or unreadable shape file keeps the diamond placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-23 03:53:41 +03:00
commit 1de4344280
4 changed files with 517 additions and 1 deletions

View file

@ -55,6 +55,42 @@ fn shape_marker(
// ── TruckConvertible ──────────────────────────────────────────────────────────
/// The shape's SHX polylines, resolved through its STYLE's shape file: the
/// style by handle (name fallback), the file with the shared path fallbacks
/// (as stored → relative to the drawing → basename next to it — foreign
/// absolute paths are the norm in traded files).
fn shx_polylines(
shape: &Shape,
document: &acadrust::CadDocument,
) -> Option<crate::scene::text::shx::ShapePolylines> {
let style = shape
.style_handle
.and_then(|h| document.text_styles.iter().find(|s| s.handle == h))
.or_else(|| {
let name = shape.style_name.trim();
(!name.is_empty())
.then(|| {
document
.text_styles
.iter()
.find(|s| s.name.eq_ignore_ascii_case(name))
})
.flatten()
})?;
let font = style.font_file.trim();
if font.is_empty() {
return None;
}
let base = document
.source_path
.as_deref()
.map(std::path::Path::new)
.and_then(|p| p.parent());
let resolved = crate::io::resolve_image_file(font, base)?;
let num = u16::try_from(shape.shape_number).ok()?;
crate::scene::text::shx::shape_polylines(&resolved, num)
}
impl TruckConvertible for Shape {
fn to_truck(&self, _document: &acadrust::CadDocument) -> Option<TruckEntity> {
// SHAPE insertion point is OCS (planar entity) — map through the
@ -70,6 +106,55 @@ impl TruckConvertible for Shape {
let size = self.size.abs().max(0.5);
let snap_pt = glam::DVec3::new(ox, oy, oz);
// Real SHX glyph when the style's shape file resolves; the diamond
// marker stays as the missing-file placeholder.
if let Some(polys) = shx_polylines(self, _document) {
let (sin_r, cos_r) = (self.rotation.sin(), self.rotation.cos());
let ob = self.oblique_angle.tan();
let xs = if self.relative_x_scale.abs() < 1e-9 {
1.0
} else {
self.relative_x_scale
};
let scale = self.size.abs().max(1e-9);
let mut pts: Vec<[f64; 3]> = Vec::new();
for poly in polys.iter() {
if poly.len() < 2 {
continue;
}
if !pts.is_empty() {
pts.push([f64::NAN; 3]);
}
for &[px, py] in poly {
// unit → entity local: size, X-scale, oblique shear, rotation.
let lx = px * scale * xs + py * scale * ob;
let ly = py * scale;
let wx = lx * cos_r - ly * sin_r;
let wy = lx * sin_r + ly * cos_r;
let (gx, gy, gz) = crate::scene::view::transform::ocs_point_to_wcs(
(
self.insertion_point.x + wx,
self.insertion_point.y + wy,
self.insertion_point.z,
),
(self.normal.x, self.normal.y, self.normal.z),
);
pts.push([gx, gy, gz]);
}
}
if !pts.is_empty() {
return Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![[ox, oy, oz]],
fill_tris: vec![],
});
}
}
let pts = shape_marker(
ox,
oy,