From 1de4344280720b5d468f49f8111a51dcb557f708 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 23 Jul 2026 03:53:41 +0300 Subject: [PATCH] feat(shape): render SHAPE entities from their compiled SHX shape file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/entities/shape.rs | 85 +++++++++ src/io/mod.rs | 4 +- src/scene/text/mod.rs | 1 + src/scene/text/shx.rs | 428 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 src/scene/text/shx.rs diff --git a/src/entities/shape.rs b/src/entities/shape.rs index b8771f6ee..7295da882 100644 --- a/src/entities/shape.rs +++ b/src/entities/shape.rs @@ -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 { + 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 { // 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, diff --git a/src/io/mod.rs b/src/io/mod.rs index 95672a9f3..f51fcc46d 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -190,6 +190,7 @@ pub fn load_file(path: &Path) -> Result { 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 { 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 { +pub(crate) fn resolve_image_file(raw: &str, base_dir: Option<&Path>) -> Option { if Path::new(raw).is_file() { return Some(raw.to_string()); } diff --git a/src/scene/text/mod.rs b/src/scene/text/mod.rs index c2c1d3145..4c0bddc09 100644 --- a/src/scene/text/mod.rs +++ b/src/scene/text/mod.rs @@ -6,3 +6,4 @@ pub mod sysfont; pub mod ttf_glyph; pub mod web_font; pub mod complex_lt; +pub mod shx; diff --git a/src/scene/text/shx.rs b/src/scene/text/shx.rs new file mode 100644 index 000000000..59b06f482 --- /dev/null +++ b/src/scene/text/shx.rs @@ -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>>; + +/// 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)>, +} + +fn file_cache() -> &'static Mutex>>> { + static C: OnceLock>>>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn shape_cache() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn load_file(path: &str) -> Option> { + 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 { + 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 { + 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 { + 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 { + let file = load_file(path)?; + let mut out: Vec> = 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>, + 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>, + 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>, + 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>, + 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); + } + } + } + } +}