diff --git a/src/scene/convert/proxy_graphics.rs b/src/scene/convert/proxy_graphics.rs index dcf16a338..e7a74769d 100644 --- a/src/scene/convert/proxy_graphics.rs +++ b/src/scene/convert/proxy_graphics.rs @@ -1,16 +1,14 @@ //! Proxy entity graphics — the cached vector preview an application stores //! alongside a custom entity so viewers without its object enabler can still //! draw something. AutoCAD falls back to exactly this; so do we (e.g. an -//! AutoCAD Architecture door/wall arrives as an `Unknown` entity we cannot -//! interpret, but it ships this preview). +//! AutoCAD Architecture door/wall, or an ACAD_TABLE, arrives as an `Unknown` +//! entity we cannot interpret, but it ships this preview). //! //! LibreDWG and ACadSharp both keep the blob raw and never parse it, so there //! is no reference decoder to copy. The layout below was reverse-engineered -//! from real previews (a Raster Design image frame, an ACA door and wall) and -//! is deliberately conservative: it reads only the primitive records it has -//! verified and treats every other record as an opaque trait to skip. Phase 1 -//! covers geometry (poly-lines, poly-gons and circular arcs); colour/lineweight -//! traits are ignored, so everything renders in the entity's own colour. +//! from real previews (a Raster Design image frame; an ACA door, wall; an +//! ACAD_TABLE) and is deliberately conservative: it reads only the primitive +//! records it has verified and treats every other record as an opaque trait. //! //! Blob grammar (all little-endian): //! ```text @@ -23,32 +21,83 @@ //! } //! ``` //! Record types decoded here: -//! * 6 poly-line / 7 poly-gon: `u32 point_count`, then `[f64;3] × point_count` -//! (type 7 closes back to the first point). -//! * 4 / 5 circular arc: `[f64;3] centre`, `f64 radius`, `[f64;3] normal`, -//! `[f64;3] start_dir`, `f64 sweep` (radians), then a trailing flag. +//! * geometry — 6 poly-line / 7 poly-gon (closed): `u32 n`, then `[f64;3]×n`; +//! 4/5 circular arc (centre, radius, normal, start dir, sweep); 9 shell +//! (`u32 n` verts, then a face list of `[u32 count, u32 idx…]`); 32 a +//! normal-tagged poly-line (`u32 n`, `[f64;3]×n`, then a normal). +//! * text — 38: position, normal, direction (its length is the glyph height), +//! then a UTF-16 content string and a UTF-16 font (`*.shx`). +//! * traits — 14 colour (plain ACI); 22 colour (encoded: 0xC2 RGB / 0xC3 ACI); +//! 23 lineweight (0.01 mm); 29 a 4×4 transform whose local→world matrix +//! applies to every following primitive (identity by default). -/// A poly-line lifted from a proxy-graphics blob, in world coordinates. Arcs -/// and closed poly-gons are pre-flattened into points so callers only draw -/// line strips. -pub struct ProxyPolyline { - pub points: Vec<[f64; 3]>, - /// The colour in force when this primitive was emitted, as an AutoCAD - /// Color Index. 256 = ByLayer / 0 = ByBlock (inherit the entity's colour); - /// 1..=255 override it. Set from the preview's colour traits. - pub color: i32, +/// The colour a preview primitive draws in, from its colour traits. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProxyColor { + /// ByLayer / ByBlock — inherit the entity's own colour. + Inherit, + /// A specific AutoCAD Color Index (1..=255). + Aci(u8), + /// A specific true colour. + Rgb(u8, u8, u8), } -/// AutoCAD Color Index meaning "inherit from the layer" — the default until a -/// colour trait says otherwise. -const COLOR_BYLAYER: i32 = 256; +/// A poly-line lifted from a proxy-graphics blob, in world coordinates. Arcs, +/// closed poly-gons and shell faces are pre-flattened into points. +pub struct ProxyPolyline { + pub points: Vec<[f64; 3]>, + /// Colour in force when this primitive was emitted. + pub color: ProxyColor, + /// Lineweight in force, 0.01 mm units; negative = inherit the entity's. + pub lineweight: i16, +} + +/// A single-line text label from a proxy-graphics blob, in world coordinates. +pub struct ProxyText { + pub position: [f64; 3], + pub height: f64, + pub rotation: f64, + pub text: String, + pub font: String, + pub color: ProxyColor, +} + +/// Everything a preview decodes to. +#[derive(Default)] +pub struct Decoded { + pub polylines: Vec, + pub texts: Vec, +} const REC_ARC: u32 = 4; const REC_ARC5: u32 = 5; const REC_POLYLINE: u32 = 6; const REC_POLYGON: u32 = 7; -/// Trait record that sets the current colour (ACI) for following primitives. +const REC_SHELL: u32 = 9; +/// Trait: current colour as a plain ACI (256 ByLayer / 0 ByBlock / 1..=255). const REC_COLOR: u32 = 14; +/// Trait: current colour in encoded form (0xC2 true colour / 0xC3 indexed). +const REC_COLOR_ENC: u32 = 22; +/// Trait: current lineweight (0.01 mm; negative = ByLayer/ByBlock/Default). +const REC_LINEWEIGHT: u32 = 23; +/// Trait: a 4×4 local→world transform for the following primitives. +const REC_TRANSFORM: u32 = 29; +/// A normal-tagged poly-line (points then a trailing normal vector). +const REC_LINE_N: u32 = 32; +/// A text label (position/normal/direction, content string, font). +const REC_TEXT: u32 = 38; + +/// Row-major 3×4 local→world transform (the bottom row of the 4×4 is 0,0,0,1). +type Xform = [f64; 12]; +const IDENTITY: Xform = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0]; + +fn apply(m: &Xform, p: [f64; 3]) -> [f64; 3] { + [ + m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3], + m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7], + m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11], + ] +} fn u32_at(b: &[u8], o: usize) -> Option { b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) @@ -64,17 +113,33 @@ fn pt3_at(b: &[u8], o: usize) -> Option<[f64; 3]> { p.iter().all(|v| v.is_finite() && v.abs() < 1e12).then_some(p) } -/// Decode every geometry record in a proxy-graphics `blob` into world-space -/// poly-lines. Returns an empty vec when the blob is absent, malformed, or -/// carries no geometry this decoder models — never any invented shape. -pub fn decode(blob: &[u8]) -> Vec { - let mut out = Vec::new(); +fn color_from_aci(v: i32) -> ProxyColor { + match v { + 1..=255 => ProxyColor::Aci(v as u8), + _ => ProxyColor::Inherit, + } +} + +fn color_from_encoded(v: u32) -> ProxyColor { + match (v >> 24) & 0xFF { + 0xC2 => ProxyColor::Rgb( + ((v >> 16) & 0xFF) as u8, + ((v >> 8) & 0xFF) as u8, + (v & 0xFF) as u8, + ), + _ => color_from_aci((v & 0x00FF_FFFF) as i32), + } +} + +/// Decode every geometry / text record in a proxy-graphics `blob` into +/// world-space primitives. Returns an empty result when the blob is absent, +/// malformed, or carries nothing this decoder models — never invented shapes. +pub fn decode(blob: &[u8]) -> Decoded { + let mut out = Decoded::default(); let total = match u32_at(blob, 0) { Some(t) => t as usize, None => return out, }; - // Trust the length field only when it fits; a mismatch means a layout this - // decoder does not model, so bail rather than walk off the end. if total > blob.len() || total < 8 { return out; } @@ -84,7 +149,10 @@ pub fn decode(blob: &[u8]) -> Vec { } let mut pos = 8usize; - let mut color = COLOR_BYLAYER; + let mut color = ProxyColor::Inherit; + let mut lineweight: i16 = -1; + let mut xform = IDENTITY; + for _ in 0..count { let Some(rsize) = u32_at(blob, pos) else { break }; let rsize = rsize as usize; @@ -95,40 +163,51 @@ pub fn decode(blob: &[u8]) -> Vec { match rtype { REC_COLOR => { if let Some(c) = u32_at(blob, pos + 8) { - color = c as i32; + color = color_from_aci(c as i32); } } - REC_POLYLINE | REC_POLYGON => { - if let Some(n) = u32_at(blob, pos + 8) { - let n = n as usize; - if n >= 2 && 12 + n * 24 <= rsize { - let mut pts = Vec::with_capacity(n + 1); - let ok = (0..n).all(|i| match pt3_at(blob, pos + 12 + i * 24) { - Some(p) => { - pts.push(p); - true - } - None => false, - }); - if ok { - // A poly-gon is a closed loop. - if rtype == REC_POLYGON { - if let Some(&first) = pts.first() { - pts.push(first); - } - } - out.push(ProxyPolyline { points: pts, color }); - } - } + REC_COLOR_ENC => { + if let Some(c) = u32_at(blob, pos + 8) { + color = color_from_encoded(c); + } + } + REC_LINEWEIGHT => { + if let Some(w) = u32_at(blob, pos + 8) { + lineweight = w as i32 as i16; + } + } + REC_TRANSFORM => { + // 4×4 row-major; keep the top 3 rows (local→world). + let mut m = IDENTITY; + if (0..12).all(|k| { + f64_at(blob, pos + 8 + 8 * k).map(|v| m[k] = v).is_some() + }) { + xform = m; + } + } + REC_POLYLINE | REC_POLYGON | REC_LINE_N => { + if let Some(pts) = decode_points(blob, pos, rsize, rtype == REC_POLYGON) { + push_line(&mut out, pts, color, lineweight, &xform); + } + } + REC_SHELL => { + for face in decode_shell(blob, pos, rsize) { + push_line(&mut out, face, color, lineweight, &xform); } } REC_ARC | REC_ARC5 => { - if let Some(points) = decode_arc(blob, pos) { - out.push(ProxyPolyline { points, color }); + if let Some(pts) = decode_arc(blob, pos) { + push_line(&mut out, pts, color, lineweight, &xform); } } - // Every other record is a trait (layer, lineweight, …) — skipped - // for now; `rsize` still advances us past it. + REC_TEXT => { + if let Some(mut t) = decode_text(blob, pos, rsize) { + t.position = apply(&xform, t.position); + t.color = color; + out.texts.push(t); + } + } + // Any other record is an unmodelled trait; `rsize` skips it. _ => {} } pos += rsize; @@ -136,9 +215,87 @@ pub fn decode(blob: &[u8]) -> Vec { out } -/// Flatten a circular-arc record (centre, radius, normal, start direction, -/// sweep) into a strip of points. The normal's Z sign gives the sweep -/// direction. +/// Transform a run of local points to world and push it as one poly-line. +fn push_line(out: &mut Decoded, local: Vec<[f64; 3]>, color: ProxyColor, lineweight: i16, xform: &Xform) { + if local.len() >= 2 { + out.polylines.push(ProxyPolyline { + points: local.iter().map(|&p| apply(xform, p)).collect(), + color, + lineweight, + }); + } +} + +/// Read `[u32 n, [f64;3]×n]` at a record; close it if `closed`. +fn decode_points(blob: &[u8], pos: usize, rsize: usize, closed: bool) -> Option> { + let n = u32_at(blob, pos + 8)? as usize; + if n < 2 || 12 + n * 24 > rsize { + return None; + } + let mut pts = Vec::with_capacity(n + 1); + for i in 0..n { + pts.push(pt3_at(blob, pos + 12 + i * 24)?); + } + if closed { + if let Some(&first) = pts.first() { + pts.push(first); + } + } + Some(pts) +} + +/// Read a shell record — `n` verts then a face list of `[count, idx…]` — and +/// return each face as a closed boundary poly-line. +fn decode_shell(blob: &[u8], pos: usize, rsize: usize) -> Vec> { + let mut faces = Vec::new(); + let Some(n) = u32_at(blob, pos + 8) else { + return faces; + }; + let n = n as usize; + let vbase = pos + 12; + if n < 2 || vbase + n * 24 > pos + rsize { + return faces; + } + let verts: Vec<[f64; 3]> = match (0..n).map(|i| pt3_at(blob, vbase + i * 24)).collect() { + Some(v) => v, + None => return faces, + }; + // Face list: [list_len, then (count, idx×count)…]. Walk indices, tolerating + // the trailing edge/visibility data by stopping at the first bad count. + let mut fp = vbase + n * 24; + let end = pos + rsize; + let _list_len = u32_at(blob, fp); // total face-list longs (unused) + fp += 4; + while fp + 4 <= end { + let Some(fc) = u32_at(blob, fp) else { break }; + let fc = fc as usize; + if fc < 2 || fc > n || fp + 4 + fc * 4 > end { + break; + } + let mut loop_pts = Vec::with_capacity(fc + 1); + let mut ok = true; + for k in 0..fc { + match u32_at(blob, fp + 4 + k * 4) { + Some(idx) if (idx as usize) < n => loop_pts.push(verts[idx as usize]), + _ => { + ok = false; + break; + } + } + } + if !ok { + break; + } + if let Some(&first) = loop_pts.first() { + loop_pts.push(first); + } + faces.push(loop_pts); + fp += 4 + fc * 4; + } + faces +} + +/// Flatten a circular-arc record into a strip of points (local coords). fn decode_arc(blob: &[u8], pos: usize) -> Option> { let center = pt3_at(blob, pos + 8)?; let radius = f64_at(blob, pos + 32)?; @@ -150,7 +307,6 @@ fn decode_arc(blob: &[u8], pos: usize) -> Option> { } let start = start_dir[1].atan2(start_dir[0]); let dir = if normal[2] < 0.0 { -1.0 } else { 1.0 }; - // Segment the sweep — ~64 per full turn, at least 2. let segs = ((sweep.abs() / std::f64::consts::TAU * 64.0).ceil() as usize).clamp(2, 512); let mut points = Vec::with_capacity(segs + 1); for i in 0..=segs { @@ -164,6 +320,60 @@ fn decode_arc(blob: &[u8], pos: usize) -> Option> { Some(points) } +/// Decode a text record: position + direction (its length is the height) + a +/// content string and font, all in local space (caller applies the transform). +fn decode_text(blob: &[u8], pos: usize, rsize: usize) -> Option { + let position = pt3_at(blob, pos + 8)?; + // doubles: pos(3), normal(3), direction(3) — direction length = height. + let dir = pt3_at(blob, pos + 8 + 48)?; + let height = (dir[0] * dir[0] + dir[1] * dir[1]).sqrt(); + let rotation = dir[1].atan2(dir[0]); + // UTF-16LE strings live at the record's tail: the content, then the font. + let data = blob.get(pos + 8..pos + rsize)?; + let mut strings = utf16_strings(data); + let font = strings + .iter() + .position(|s| s.to_ascii_lowercase().ends_with(".shx")) + .map(|i| strings.remove(i)) + .unwrap_or_default(); + let text = strings.into_iter().next().unwrap_or_default(); + if text.trim().is_empty() || !height.is_finite() || height <= 0.0 { + return None; + } + Some(ProxyText { + position, + height, + rotation, + text, + font, + color: ProxyColor::Inherit, + }) +} + +/// Pull the printable-ASCII UTF-16LE runs (≥ 2 chars) out of a byte slice. +fn utf16_strings(data: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut i = 0; + while i + 1 < data.len() { + let (lo, hi) = (data[i], data[i + 1]); + if hi == 0 && (0x20..0x7f).contains(&lo) { + cur.push(lo as char); + } else { + if cur.len() >= 2 { + out.push(std::mem::take(&mut cur)); + } else { + cur.clear(); + } + } + i += 2; + } + if cur.len() >= 2 { + out.push(cur); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -186,19 +396,19 @@ mod tests { .map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap(), 16).unwrap()) .collect(); assert_eq!(blob.len(), 140); - let polys = decode(&blob); - assert_eq!(polys.len(), 1); - assert_eq!(polys[0].points.len(), 5); - assert!((polys[0].points[2][0] - 1192.1490).abs() < 1e-3); - assert!((polys[0].points[2][1] - 877.8240).abs() < 1e-3); + let dec = decode(&blob); + assert_eq!(dec.polylines.len(), 1); + assert_eq!(dec.polylines[0].points.len(), 5); + assert!((dec.polylines[0].points[2][0] - 1192.1490).abs() < 1e-3); + assert!((dec.polylines[0].points[2][1] - 877.8240).abs() < 1e-3); } #[test] fn rejects_anything_it_does_not_model() { - assert!(decode(&[]).is_empty()); - assert!(decode(&[0u8; 140]).is_empty()); // size field wrong + assert!(decode(&[]).polylines.is_empty()); + assert!(decode(&[0u8; 140]).polylines.is_empty()); let mut b = vec![0u8; 140]; - b[0] = 140; // right size, zero records - assert!(decode(&b).is_empty()); + b[0] = 140; + assert!(decode(&b).polylines.is_empty()); } } diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 98e18289c..cba49fa7c 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -244,41 +244,99 @@ pub(crate) fn tessellate_entity( // occupies its real place instead of silently disappearing. if let EntityType::Unknown(_) = e { if let Some(blob) = e.common().graphic_data.as_ref() { - let polys = convert::proxy_graphics::decode(blob); - if !polys.is_empty() { - // Group primitives by their preview colour so each colour draws - // in its own wire (the poly-lines within share a wire, joined by - // NaN separators; arcs are already flattened to points). + let dec = convert::proxy_graphics::decode(blob); + if !dec.polylines.is_empty() || !dec.texts.is_empty() { + use crate::scene::convert::proxy_graphics::ProxyColor; use std::collections::BTreeMap; let nan = [f64::NAN; 3]; - let mut by_color: BTreeMap> = BTreeMap::new(); - for poly in &polys { - let buf = by_color.entry(poly.color).or_default(); + // A specific ACI / RGB overrides the entity colour; ByLayer / + // ByBlock inherit it. + let resolve = |pc: ProxyColor| -> ([f32; 4], u8) { + match pc { + ProxyColor::Aci(a) => ( + view::render::adapt_to_bg( + convert::tess_util::aci_to_rgba(&acadrust::types::Color::Index(a)), + bg_color, + ), + a, + ), + ProxyColor::Rgb(r, g, b) => ( + view::render::adapt_to_bg( + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0], + bg_color, + ), + 0, + ), + ProxyColor::Inherit => (entity_color, aci), + } + }; + let mut wires = Vec::new(); + // Lines / shells: group by (colour, lineweight), one wire each. + let mut groups: BTreeMap<(ProxyColor, i16), Vec<[f64; 3]>> = BTreeMap::new(); + for poly in &dec.polylines { + let buf = groups.entry((poly.color, poly.lineweight)).or_default(); if !buf.is_empty() { buf.push(nan); } buf.extend_from_slice(&poly.points); } - let mut wires = Vec::with_capacity(by_color.len()); - for (pcolor, pts64) in by_color { - // A real ACI index overrides the entity colour; ByLayer - // (256) / ByBlock (0) inherit it. - let (col, w_aci) = if (1..=255).contains(&pcolor) { - let rgba = convert::tess_util::aci_to_rgba( - &acadrust::types::Color::Index(pcolor as u8), - ); - (view::render::adapt_to_bg(rgba, bg_color), pcolor as u8) + for ((pcolor, plw), pts64) in groups { + let (col, w_aci) = resolve(pcolor); + let lw_px = if plw >= 0 { + view::render::lineweight_to_px(&acadrust::types::LineWeight::Value(plw)) } else { - (entity_color, aci) + line_weight_px }; let (pts, pts_low) = convert::tessellate::points_to_ds(pts64); let mut w = WireModel::solid(h.value().to_string(), pts, col, sel); w.points_low = pts_low; - w.line_weight_px = line_weight_px; + w.line_weight_px = lw_px; w.aci = w_aci; wires.push(w); } - return wires; + // Text labels: draw the glyph strokes (simplex.shx etc. are + // single-stroke fonts, so the outline is the character). + for t in &dec.texts { + let font = t.font.trim().trim_end_matches(".shx").trim_end_matches(".SHX"); + let font = if font.is_empty() { "standard" } else { font }; + let (strokes, _) = crate::scene::text::lff::tessellate_text_ex( + [0.0, 0.0], + t.height as f32, + t.rotation as f32, + 1.0, + 0.0, + font, + &t.text, + ); + let mut pts64: Vec<[f64; 3]> = Vec::new(); + for stroke in &strokes { + if stroke.len() < 2 { + continue; + } + if !pts64.is_empty() { + pts64.push(nan); + } + for &[x, y] in stroke { + pts64.push([ + t.position[0] + x as f64, + t.position[1] + y as f64, + t.position[2], + ]); + } + } + if pts64.len() >= 2 { + let (col, w_aci) = resolve(t.color); + let (pts, pts_low) = convert::tessellate::points_to_ds(pts64); + let mut w = WireModel::solid(h.value().to_string(), pts, col, sel); + w.points_low = pts_low; + w.line_weight_px = line_weight_px; + w.aci = w_aci; + wires.push(w); + } + } + if !wires.is_empty() { + return wires; + } } } }