feat(proxy): decode shell, transformed geometry and text previews

Extends the proxy-graphics decoder past the door/wall's absolute polylines
so an ACAD_TABLE preview renders in full:
- type 29 transform: a 4x4 local->world matrix now applies to every
  following primitive (identity for the door/wall, which are absolute).
- type 9 shell: vertices + a face list -> each face as a closed boundary
  (the table's cell rectangles).
- type 32: a normal-tagged polyline (grid lines).
- type 38 text: position + direction (its length is the glyph height) +
  UTF-16 content and font, drawn as glyph strokes (simplex.shx and kin are
  single-stroke fonts, so the outline is the character).

True-colour (22) and lineweight (23) traits now reach real geometry too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-18 21:34:15 +03:00
commit 69354eca07
2 changed files with 359 additions and 91 deletions

View file

@ -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<ProxyPolyline>,
pub texts: Vec<ProxyText>,
}
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<u32> {
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<ProxyPolyline> {
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<ProxyPolyline> {
}
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<ProxyPolyline> {
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<ProxyPolyline> {
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<Vec<[f64; 3]>> {
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<Vec<[f64; 3]>> {
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<Vec<[f64; 3]>> {
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<Vec<[f64; 3]>> {
}
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<Vec<[f64; 3]>> {
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<ProxyText> {
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<String> {
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());
}
}

View file

@ -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<i32, Vec<[f64; 3]>> = 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;
}
}
}
}