feat(table): coloured synthesis render honouring all TableStyle fields

Tables without a baked block (e.g. created in-app) were drawn as a single
monochrome wire, ignoring cell colours/fills/border styling. Add a
coloured synthesis path that emits per-colour WireModels for:
- cell background fill (fill_color + fill_enabled),
- per-cell text in its content/text colour,
- grid borders with per-edge type/weight/colour/visibility (cell-style
  override → row-style → default),
- horizontal/vertical margins and flow direction (down/up).

Imported tables keep using AutoCAD's baked block. Resolution layers the
per-cell CellStyle over the TableStyle row style over defaults.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-04 02:28:12 +03:00
1 parent 90c9ac62c8
commit 1b20d7d781
2 files changed
+379

No files matched your search

+365
View File
@@ -316,6 +316,371 @@ impl TruckConvertible for Table {
}
}
/// Coloured synthesis render for tables WITHOUT a baked block (e.g. tables
/// created in-app). Emits one `WireModel` per distinct colour for cell fills,
/// per-cell text, and grid borders, honouring every `TableStyle` /
/// `RowCellStyle` / per-cell `CellStyle` field: fill colour + enable, text
/// colour, border type/weight/colour/visibility (incl. inside borders),
/// margins, and flow direction. Imported tables keep using AutoCAD's baked
/// block (see scene/mod.rs).
pub fn tessellate_table(
tab: &Table,
document: &acadrust::CadDocument,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
) -> Vec<crate::scene::wire_model::WireModel> {
use crate::scene::tess_util::aci_to_rgba;
use crate::scene::wire_model::WireModel;
use acadrust::types::Color;
use std::collections::HashMap;
if tab.rows.is_empty() || tab.columns.is_empty() {
return Vec::new();
}
let [ox, oy, oz] = world_offset;
let rel = |p: Vec3| -> [f32; 3] {
[
(p.x as f64 - ox) as f32,
(p.y as f64 - oy) as f32,
(p.z as f64 - oz) as f32,
]
};
let resolve_col = |c: &Color, fallback: [f32; 4]| -> [f32; 4] {
match c {
Color::ByLayer | Color::ByBlock => fallback,
_ => aci_to_rgba(c),
}
};
let key4 = |c: [f32; 4]| -> [u8; 4] {
[
(c[0] * 255.0) as u8,
(c[1] * 255.0) as u8,
(c[2] * 255.0) as u8,
(c[3] * 255.0) as u8,
]
};
let lw_px = |w: &acadrust::types::LineWeight| -> f32 {
match w {
acadrust::types::LineWeight::Value(v) if *v >= 0 => (*v as f32 / 100.0) * (96.0 / 25.4),
_ => line_weight_px,
}
};
let origin = v3(&tab.insertion_point);
let h_raw = v3(&tab.horizontal_direction);
let h = if h_raw.length_squared() > 1e-10 {
h_raw.normalize()
} else {
Vec3::X
};
let v_down = Vec3::new(h.y, -h.x, 0.0);
// Flow direction: `Up` stacks rows upward instead of downward.
let table_style: Option<&acadrust::objects::TableStyle> = tab.table_style_handle.and_then(|h| {
document.objects.get(&h).and_then(|obj| match obj {
acadrust::objects::ObjectType::TableStyle(ts) => Some(ts),
_ => None,
})
});
let flow_up = matches!(
table_style.map(|t| t.flow_direction),
Some(acadrust::objects::TableFlowDirection::Up)
);
let v_flow = if flow_up { -v_down } else { v_down };
let col_offsets: Vec<f32> = {
let mut off = 0.0f32;
let mut v = vec![0.0f32];
for col in &tab.columns {
off += col.width as f32;
v.push(off);
}
v
};
let row_offsets: Vec<f32> = {
let mut off = 0.0f32;
let mut v = vec![0.0f32];
for row in &tab.rows {
off += row.height as f32;
v.push(off);
}
v
};
let title_suppressed = table_style.map(|t| t.title_suppressed).unwrap_or(false);
let header_suppressed = table_style.map(|t| t.header_suppressed).unwrap_or(false);
let h_margin = table_style.map(|t| t.horizontal_margin as f32).unwrap_or(0.0);
let v_margin = table_style.map(|t| t.vertical_margin as f32).unwrap_or(0.0);
let lookup_style = |hh: acadrust::Handle| -> Option<&acadrust::tables::TextStyle> {
document.text_styles.iter().find(|s| s.handle == hh)
};
let font_for_handle = |handle: Option<acadrust::Handle>| -> Option<String> {
handle.and_then(lookup_style).and_then(|s| {
let file = s.font_file.trim();
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
let stem = basename.split('.').next().unwrap_or(basename).trim();
(!stem.is_empty()).then(|| stem.to_string())
})
};
let resolved_style_for_handle =
|handle: Option<acadrust::Handle>, font_name: String| -> ResolvedTextStyle {
let style = handle.and_then(lookup_style);
ResolvedTextStyle {
font_name,
width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0),
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
}
};
// Accumulators keyed by quantised colour (+ weight for borders).
let mut fills: HashMap<[u8; 4], ([f32; 4], Vec<[f32; 3]>)> = HashMap::new();
let mut texts: HashMap<[u8; 4], ([f32; 4], Vec<[f32; 3]>)> = HashMap::new();
let mut borders: HashMap<([u8; 4], u32), ([f32; 4], f32, Vec<[f32; 3]>)> = HashMap::new();
let mut emitted: std::collections::HashSet<(i32, i32, i32, i32)> = std::collections::HashSet::new();
let sel_col = WireModel::SELECTED;
let mut add_edge =
|a: Vec3, b: Vec3, col: [f32; 4], lw: f32| {
let k = (
(a.x * 1000.0) as i32,
(a.y * 1000.0) as i32,
(b.x * 1000.0) as i32,
(b.y * 1000.0) as i32,
);
let kr = (k.2, k.3, k.0, k.1);
if emitted.contains(&k) || emitted.contains(&kr) {
return;
}
emitted.insert(k);
let entry = borders
.entry((key4(col), (lw * 100.0) as u32))
.or_insert_with(|| (col, lw, Vec::new()));
if !entry.2.is_empty() {
entry.2.push([f32::NAN; 3]);
}
entry.2.push(rel(a));
entry.2.push(rel(b));
};
for (ri, row) in tab.rows.iter().enumerate() {
let row_top = row_offsets[ri];
let row_bot = row_offsets
.get(ri + 1)
.copied()
.unwrap_or(row_top + row.height as f32);
let row_mid = (row_top + row_bot) * 0.5;
let row_style: Option<&acadrust::objects::RowCellStyle> = table_style.map(|ts| {
let kind = match (title_suppressed, header_suppressed, ri) {
(false, _, 0) => 0,
(false, false, 1) => 1,
(true, false, 0) => 1,
_ => 2,
};
match kind {
0 => &ts.title_row_style,
1 => &ts.header_row_style,
_ => &ts.data_row_style,
}
});
for (ci, cell) in row.cells.iter().enumerate() {
let col_left = col_offsets[ci];
let col_width = tab.columns.get(ci).map(|c| c.width as f32).unwrap_or(1.0);
let col_right = col_left + col_width;
let tl = origin + h * col_left + v_flow * row_top;
let tr = origin + h * col_right + v_flow * row_top;
let br_ = origin + h * col_right + v_flow * row_bot;
let bl = origin + h * col_left + v_flow * row_bot;
// ── Fill ──────────────────────────────────────────────────────
let (fill_on, fill_color) = if let Some(cs) = &cell.style {
(cs.fill_enabled, cs.background_color)
} else if let Some(rs) = row_style {
(rs.fill_enabled, rs.fill_color)
} else {
(false, Color::ByLayer)
};
if fill_on {
let col = resolve_col(&fill_color, entity_color);
let buf = &mut fills.entry(key4(col)).or_insert_with(|| (col, Vec::new())).1;
for v in [bl, br_, tr, bl, tr, tl] {
buf.push(rel(v));
}
}
// ── Borders (per edge: cell override → row style → default) ───
// (top, right, bottom, left)
let cell_b = cell.style.as_ref();
let edge = |which: u8| -> (bool, [f32; 4], f32) {
if let Some(cs) = cell_b {
let b = match which {
0 => &cs.top_border,
1 => &cs.right_border,
2 => &cs.bottom_border,
_ => &cs.left_border,
};
(
!b.invisible,
if selected { sel_col } else { resolve_col(&b.color, entity_color) },
lw_px(&b.line_weight),
)
} else if let Some(rs) = row_style {
let b = match which {
0 => &rs.top_border,
1 => &rs.right_border,
2 => &rs.bottom_border,
_ => &rs.left_border,
};
(
!b.is_invisible,
if selected { sel_col } else { resolve_col(&b.color, entity_color) },
lw_px(&b.line_weight),
)
} else {
(true, if selected { sel_col } else { entity_color }, line_weight_px)
}
};
let (tv, tc, tw) = edge(0);
if tv { add_edge(tl, tr, tc, tw); }
let (rv, rc, rw) = edge(1);
if rv { add_edge(tr, br_, rc, rw); }
let (bv, bc, bw) = edge(2);
if bv { add_edge(bl, br_, bc, bw); }
let (lv, lc, lw) = edge(3);
if lv { add_edge(tl, bl, lc, lw); }
// ── Text ──────────────────────────────────────────────────────
let text = cell.text_value();
if text.is_empty() {
continue;
}
let content = cell.contents.first();
let cell_h = content
.map(|c| c.text_height)
.filter(|h| *h > 1e-6)
.or_else(|| cell.style.as_ref().map(|s| s.text_height).filter(|h| *h > 1e-6))
.or_else(|| row_style.map(|s| s.text_height).filter(|h| *h > 1e-6))
.map(|h| h as f32)
.unwrap_or(0.18);
let m_h = if h_margin > 1e-6 { h_margin } else { cell_h * 0.5 };
let m_v = if v_margin > 1e-6 { v_margin } else { cell_h * 0.5 };
let style_handle = content
.and_then(|c| c.text_style_handle)
.or_else(|| cell.style.as_ref().and_then(|s| s.text_style_handle))
.or_else(|| row_style.and_then(|s| s.text_style_handle));
let font_owned = font_for_handle(style_handle).unwrap_or_else(|| "txt".to_string());
let resolved = resolved_style_for_handle(style_handle, font_owned);
let align = cell
.style
.as_ref()
.map(|s| s.alignment)
.filter(|a| *a != 0)
.or_else(|| row_style.map(|s| s.alignment as i32))
.unwrap_or(5);
let horiz = ((align - 1).rem_euclid(3)) + 1;
let vert = ((align - 1) / 3) + 1;
let (x_offset, attach_h_anchor) = match horiz {
1 => (col_left + m_h, 0.0_f32),
3 => (col_right - m_h, 1.0_f32),
_ => (col_left + col_width * 0.5, 0.5_f32),
};
let (y_offset, v_anchor) = match vert {
1 => (row_top + m_v, MTextVAnchor::Top),
3 => (row_bot - m_v, MTextVAnchor::Bottom),
_ => (row_mid, MTextVAnchor::Middle),
};
let to = origin + h * x_offset + v_flow * y_offset;
let rot = content.map(|c| c.rotation as f32).unwrap_or(0.0) + cell.rotation as f32;
let layout = layout_mtext(&MTextRenderOpts {
value: text,
insertion: [(to.x as f64 - ox), (to.y as f64 - oy), (to.z as f64 - oz)],
height: cell_h,
rect_w: 0.0,
rotation: rot,
style: &resolved,
attach_h_anchor,
v_anchor,
line_spacing_factor: 1.0,
vertical_text: false,
want_glyph_boxes: false,
});
let tcol = if selected {
sel_col
} else if let Some(cs) = &cell.style {
resolve_col(&cs.content_color, entity_color)
} else if let Some(rs) = row_style {
resolve_col(&rs.text_color, entity_color)
} else {
entity_color
};
let buf = &mut texts.entry(key4(tcol)).or_insert_with(|| (tcol, Vec::new())).1;
for ts in &layout.strokes {
let sx = ts.origin[0] as f32;
let sy = ts.origin[1] as f32;
for stroke in &ts.strokes {
if stroke.len() < 2 {
continue;
}
if !buf.is_empty() {
buf.push([f32::NAN; 3]);
}
for &[x, y] in stroke {
buf.push([x + sx, y + sy, (to.z as f64 - oz) as f32]);
}
}
}
}
}
let name = tab.common.handle.value().to_string();
let mk = |color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel {
WireModel {
name: name.clone(),
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: lw,
aci: 0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris,
}
};
let mut out: Vec<WireModel> = Vec::new();
// Fills first (drawn under borders/text).
for (_, (color, tris)) in fills {
if !tris.is_empty() {
out.push(mk(color, vec![], tris, 1.0));
}
}
for (_, (color, lw, pts)) in borders {
if !pts.is_empty() {
out.push(mk(color, pts, vec![], lw));
}
}
for (_, (color, pts)) in texts {
if !pts.is_empty() {
out.push(mk(color, pts, vec![], line_weight_px));
}
}
out
}
impl Grippable for Table {
fn grips(&self) -> Vec<GripDef> {
vec![square_grip(0, v3(&self.insertion_point))]
+14
View File
@@ -6734,6 +6734,20 @@ fn tessellate_entity(
}
}
}
// No baked block (e.g. a table created in-app) — synthesise coloured
// geometry from the rows + TableStyle so fills/colours/borders/margins
// are honoured instead of the monochrome fallback.
let mut wires = crate::entities::table::tessellate_table(
tab, document, sel, entity_color, line_weight_px, world_offset,
);
if !wires.is_empty() {
let aabb = entity_aabb(e, world_offset);
for w in &mut wires {
w.aci = aci;
w.aabb = aabb;
}
return wires;
}
}
if let EntityType::Insert(ins) = e {