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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-23 03:53:41 +03:00
1 parent f4f8c0687c
commit 1de4344280
4 files changed
+517 -1

No files matched your search

+85
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,
+3 -1
View File
@@ -190,6 +190,7 @@ pub fn load_file(path: &Path) -> Result<CadDocument, String> {
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
resolve_raster_image_paths(&mut doc, path.parent());
doc.source_path = Some(path.to_string_lossy().into_owned());
Ok(doc)
}
"dxf" => {
@@ -201,6 +202,7 @@ pub fn load_file(path: &Path) -> Result<CadDocument, String> {
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
resolve_raster_image_paths(&mut doc, path.parent());
doc.source_path = Some(path.to_string_lossy().into_owned());
Ok(doc)
}
_ => Err(format!("Unsupported file format: .{ext}")),
@@ -265,7 +267,7 @@ fn resolve_raster_image_paths(doc: &mut CadDocument, base_dir: Option<&Path>) {
/// Resolve a (possibly foreign / absolute) image path to an existing file:
/// as stored, then relative to the drawing folder, then just the file name
/// next to the drawing.
fn resolve_image_file(raw: &str, base_dir: Option<&Path>) -> Option<String> {
pub(crate) fn resolve_image_file(raw: &str, base_dir: Option<&Path>) -> Option<String> {
if Path::new(raw).is_file() {
return Some(raw.to_string());
}
+1
View File
@@ -6,3 +6,4 @@ pub mod sysfont;
pub mod ttf_glyph;
pub mod web_font;
pub mod complex_lt;
pub mod shx;
+428
View File
@@ -0,0 +1,428 @@
// Compiled AutoCAD shape-file (.SHX) reader + shape tessellator.
//
// Parses the classic `AutoCAD-86 shapes 1.0/1.1` container (the format SHAPE
// entities and complex-linetype shapes reference) and interprets a shape's
// bytecode into unit-space polylines. Fonts (`AutoCAD-86 unifont` etc.) are
// out of scope here — text keeps going through the LFF/TTF substitutes.
//
// Bytecode reference (each item, pen starts DOWN at the origin):
// 0 end of shape
// 1 / 2 pen down / pen up
// 3 n / 4 n divide / multiply the vector scale by n
// 5 / 6 push / pop the current position (4-deep stack)
// 7 n draw subshape n
// 8 dx dy signed byte displacement
// 9 … repeated signed (dx,dy) pairs until (0,0)
// 0xA r f octant arc: radius, flags (sign|start-octant|octant-count)
// 0xB s e hr r f fractional arc
// 0xC dx dy b bulge segment; 0xD … repeated until (0,0)
// 0xE vertical-text-only flag: skip the NEXT item horizontally
// else vector: high nibble = length, low nibble = one of 16
// predefined directions
//
// Results are cached per (file, shape-number); parse tables per file.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
/// One shape's tessellation in unit space: polylines of XY points.
pub type ShapePolylines = Arc<Vec<Vec<[f64; 2]>>>;
/// The 16 SHX vector directions. Diagonal-ish entries deliberately use the
/// classic half-steps (not normalized) — lengths scale per direction exactly
/// as AutoCAD draws them.
const DIRS: [[f64; 2]; 16] = [
[1.0, 0.0],
[1.0, 0.5],
[1.0, 1.0],
[0.5, 1.0],
[0.0, 1.0],
[-0.5, 1.0],
[-1.0, 1.0],
[-1.0, 0.5],
[-1.0, 0.0],
[-1.0, -0.5],
[-1.0, -1.0],
[-0.5, -1.0],
[0.0, -1.0],
[0.5, -1.0],
[1.0, -1.0],
[1.0, -0.5],
];
struct ShxFile {
/// shape number → (name, spec bytes)
shapes: HashMap<u16, (String, Vec<u8>)>,
}
fn file_cache() -> &'static Mutex<HashMap<String, Option<Arc<ShxFile>>>> {
static C: OnceLock<Mutex<HashMap<String, Option<Arc<ShxFile>>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
fn shape_cache() -> &'static Mutex<HashMap<(String, u16), Option<ShapePolylines>>> {
static C: OnceLock<Mutex<HashMap<(String, u16), Option<ShapePolylines>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
fn load_file(path: &str) -> Option<Arc<ShxFile>> {
if let Ok(c) = file_cache().lock() {
if let Some(hit) = c.get(path) {
return hit.clone();
}
}
let parsed = parse_file(path).map(Arc::new);
if let Ok(mut c) = file_cache().lock() {
c.insert(path.to_string(), parsed.clone());
}
parsed
}
fn parse_file(path: &str) -> Option<ShxFile> {
let bytes = std::fs::read(path).ok()?;
// Header line: `AutoCAD-86 shapes 1.0`/`1.1` + CR LF SUB. Reject fonts.
let head_end = bytes.iter().position(|&b| b == 0x1A)?;
let header = String::from_utf8_lossy(&bytes[..head_end]);
if !header.contains("shapes") {
return None;
}
let mut p = head_end + 1;
let rd_u16 = |p: &mut usize| -> Option<u16> {
let v = u16::from_le_bytes([*bytes.get(*p)?, *bytes.get(*p + 1)?]);
*p += 2;
Some(v)
};
let _first = rd_u16(&mut p)?;
let _last = rd_u16(&mut p)?;
let count = rd_u16(&mut p)? as usize;
let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count);
for _ in 0..count {
let num = rd_u16(&mut p)?;
let len = rd_u16(&mut p)? as usize;
dir.push((num, len));
}
let mut shapes = HashMap::new();
for (num, len) in dir {
let end = p.checked_add(len)?.min(bytes.len());
let blob = &bytes[p..end];
p = end;
// Name = leading NUL-terminated ASCII, spec follows.
let name_end = blob.iter().position(|&b| b == 0).unwrap_or(0);
let name = String::from_utf8_lossy(&blob[..name_end]).into_owned();
let spec = blob.get(name_end + 1..).unwrap_or(&[]).to_vec();
shapes.insert(num, (name, spec));
}
Some(ShxFile { shapes })
}
/// Tessellate `shape_number` from the SHX at `path` into unit-space
/// polylines. `None` when the file/shape is missing or not a shapes file.
pub fn shape_polylines(path: &str, shape_number: u16) -> Option<ShapePolylines> {
let key = (path.to_string(), shape_number);
if let Ok(c) = shape_cache().lock() {
if let Some(hit) = c.get(&key) {
return hit.clone();
}
}
let built = build_shape(path, shape_number);
if let Ok(mut c) = shape_cache().lock() {
c.insert(key, built.clone());
}
built
}
fn build_shape(path: &str, shape_number: u16) -> Option<ShapePolylines> {
let file = load_file(path)?;
let mut out: Vec<Vec<[f64; 2]>> = Vec::new();
let mut cur: Vec<[f64; 2]> = vec![[0.0, 0.0]];
let mut st = InterpState {
x: 0.0,
y: 0.0,
scale: 1.0,
pen_down: true,
stack: Vec::new(),
};
interpret(&file, shape_number, &mut st, &mut cur, &mut out, 0);
if cur.len() > 1 {
out.push(cur);
}
if out.is_empty() {
return None;
}
Some(Arc::new(out))
}
struct InterpState {
x: f64,
y: f64,
scale: f64,
pen_down: bool,
stack: Vec<(f64, f64)>,
}
/// Append a straight segment / move in interpreter space.
fn advance(
st: &mut InterpState,
cur: &mut Vec<[f64; 2]>,
out: &mut Vec<Vec<[f64; 2]>>,
dx: f64,
dy: f64,
) {
st.x += dx * st.scale;
st.y += dy * st.scale;
if st.pen_down {
cur.push([st.x, st.y]);
} else {
if cur.len() > 1 {
out.push(std::mem::take(cur));
}
cur.clear();
cur.push([st.x, st.y]);
}
}
/// Arc from the current point: `start`/`sweep` in radians over `r` (already
/// scale-applied). Sampled into short segments.
fn emit_arc(
st: &mut InterpState,
cur: &mut Vec<[f64; 2]>,
out: &mut Vec<Vec<[f64; 2]>>,
r: f64,
start: f64,
sweep: f64,
) {
if r <= 0.0 || sweep == 0.0 {
return;
}
let cx = st.x - r * start.cos();
let cy = st.y - r * start.sin();
let steps = ((sweep.abs() / std::f64::consts::TAU * 32.0).ceil() as usize).max(2);
for i in 1..=steps {
let a = start + sweep * i as f64 / steps as f64;
let nx = cx + r * a.cos();
let ny = cy + r * a.sin();
if st.pen_down {
cur.push([nx, ny]);
} else if i == steps {
if cur.len() > 1 {
out.push(std::mem::take(cur));
}
cur.clear();
cur.push([nx, ny]);
}
st.x = nx;
st.y = ny;
}
}
/// Bulge segment (like LWPOLYLINE bulge, in ±127 units).
fn emit_bulge(
st: &mut InterpState,
cur: &mut Vec<[f64; 2]>,
out: &mut Vec<Vec<[f64; 2]>>,
dx: f64,
dy: f64,
bulge: f64,
) {
let (sx, sy) = (st.x, st.y);
let (ex, ey) = (sx + dx * st.scale, sy + dy * st.scale);
let b = bulge / 127.0;
if b.abs() < 1e-6 || !st.pen_down {
advance(st, cur, out, dx, dy);
return;
}
// Bulge = tan(theta/4); sample the arc through the chord.
let theta = 4.0 * b.atan();
let chord = ((ex - sx).powi(2) + (ey - sy).powi(2)).sqrt();
if chord < 1e-12 {
return;
}
let r = chord / (2.0 * (theta / 2.0).sin().abs());
let mid_x = (sx + ex) * 0.5;
let mid_y = (sy + ey) * 0.5;
let d = (r * r - chord * chord / 4.0).max(0.0).sqrt() * if theta > 0.0 { 1.0 } else { -1.0 };
let (nx, ny) = (-(ey - sy) / chord, (ex - sx) / chord);
let (cx, cy) = (mid_x + nx * d, mid_y + ny * d);
let a0 = (sy - cy).atan2(sx - cx);
let steps = ((theta.abs() / std::f64::consts::TAU * 32.0).ceil() as usize).max(2);
for i in 1..=steps {
let a = a0 + theta * i as f64 / steps as f64;
let px = cx + r * a.cos();
let py = cy + r * a.sin();
cur.push([px, py]);
st.x = px;
st.y = py;
}
st.x = ex;
st.y = ey;
if let Some(last) = cur.last_mut() {
*last = [ex, ey];
}
}
fn interpret(
file: &ShxFile,
shape_number: u16,
st: &mut InterpState,
cur: &mut Vec<[f64; 2]>,
out: &mut Vec<Vec<[f64; 2]>>,
depth: usize,
) {
if depth > 4 {
return;
}
let Some((_, spec)) = file.shapes.get(&shape_number) else {
return;
};
let b = spec.as_slice();
let mut i = 0usize;
let mut skip_next = false;
while i < b.len() {
let code = b[i];
i += 1;
// 0xE: the next item applies to vertical text only — decode it to
// know its length, but discard its effect.
let skipping = skip_next;
skip_next = false;
match code {
0 => return,
1 => {
if !skipping {
st.pen_down = true;
}
}
2 => {
if !skipping {
st.pen_down = false;
}
}
3 | 4 => {
let n = *b.get(i).unwrap_or(&1) as f64;
i += 1;
if !skipping && n > 0.0 {
if code == 3 {
st.scale /= n;
} else {
st.scale *= n;
}
}
}
5 => {
if !skipping && st.stack.len() < 4 {
st.stack.push((st.x, st.y));
}
}
6 => {
if !skipping {
if let Some((px, py)) = st.stack.pop() {
st.x = px;
st.y = py;
if cur.len() > 1 {
out.push(std::mem::take(cur));
}
cur.clear();
cur.push([px, py]);
}
}
}
7 => {
let sub = *b.get(i).unwrap_or(&0) as u16;
i += 1;
if !skipping && sub != 0 && sub != shape_number {
interpret(file, sub, st, cur, out, depth + 1);
}
}
8 => {
let dx = *b.get(i).unwrap_or(&0) as i8 as f64;
let dy = *b.get(i + 1).unwrap_or(&0) as i8 as f64;
i += 2;
if !skipping {
advance(st, cur, out, dx, dy);
}
}
9 => loop {
let dx = *b.get(i).unwrap_or(&0) as i8 as f64;
let dy = *b.get(i + 1).unwrap_or(&0) as i8 as f64;
i += 2;
if dx == 0.0 && dy == 0.0 {
break;
}
if !skipping {
advance(st, cur, out, dx, dy);
}
},
0xA => {
let r = *b.get(i).unwrap_or(&0) as f64;
let f = *b.get(i + 1).unwrap_or(&0);
i += 2;
if !skipping {
let cw = f & 0x80 != 0;
let start_oct = ((f >> 4) & 7) as f64;
let mut n_oct = (f & 7) as f64;
if n_oct == 0.0 {
n_oct = 8.0;
}
let start = start_oct * std::f64::consts::FRAC_PI_4;
let sweep = n_oct * std::f64::consts::FRAC_PI_4 * if cw { -1.0 } else { 1.0 };
emit_arc(st, cur, out, r * st.scale, start, sweep);
}
}
0xB => {
let so = *b.get(i).unwrap_or(&0) as f64;
let eo = *b.get(i + 1).unwrap_or(&0) as f64;
let hr = *b.get(i + 2).unwrap_or(&0) as f64;
let r = *b.get(i + 3).unwrap_or(&0) as f64;
let f = *b.get(i + 4).unwrap_or(&0);
i += 5;
if !skipping {
let radius = (hr * 256.0 + r) * st.scale;
let cw = f & 0x80 != 0;
let start_oct = ((f >> 4) & 7) as f64;
let mut n_oct = (f & 7) as f64;
if n_oct == 0.0 {
n_oct = 8.0;
}
let q = std::f64::consts::FRAC_PI_4;
let start = start_oct * q + so * q / 256.0 * if cw { -1.0 } else { 1.0 };
let total = n_oct * q;
let sweep_mag = total - so * q / 256.0 - (255.0 - eo) * q / 256.0
+ if eo == 0.0 { 0.0 } else { 0.0 };
let sweep = sweep_mag.max(0.0) * if cw { -1.0 } else { 1.0 };
emit_arc(st, cur, out, radius, start, sweep);
}
}
0xC => {
let dx = *b.get(i).unwrap_or(&0) as i8 as f64;
let dy = *b.get(i + 1).unwrap_or(&0) as i8 as f64;
let bu = *b.get(i + 2).unwrap_or(&0) as i8 as f64;
i += 3;
if !skipping {
emit_bulge(st, cur, out, dx, dy, bu);
}
}
0xD => loop {
let dx = *b.get(i).unwrap_or(&0) as i8 as f64;
let dy = *b.get(i + 1).unwrap_or(&0) as i8 as f64;
i += 2;
if dx == 0.0 && dy == 0.0 {
break;
}
let bu = *b.get(i).unwrap_or(&0) as i8 as f64;
i += 1;
if !skipping {
emit_bulge(st, cur, out, dx, dy, bu);
}
},
0xE => {
skip_next = true;
}
v => {
let len = (v >> 4) as f64;
let dir = DIRS[(v & 0xF) as usize];
if !skipping {
advance(st, cur, out, dir[0] * len, dir[1] * len);
}
}
}
}
}