From 1b20d7d7816ae34074bb1c60753da0a85feedfc6 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 4 Jun 2026 02:28:12 +0300 Subject: [PATCH] feat(table): coloured synthesis render honouring all TableStyle fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/entities/table.rs | 365 ++++++++++++++++++++++++++++++++++++++++++ src/scene/mod.rs | 14 ++ 2 files changed, 379 insertions(+) diff --git a/src/entities/table.rs b/src/entities/table.rs index af240d8e0..44559eba0 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -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 { + 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 = { + 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 = { + 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| -> Option { + 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, 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 = 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 { vec![square_grip(0, v3(&self.insertion_point))] diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 47cca0240..3f0e3814a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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 {