forked from 1b-agent/opencadstudio-upstream
3616 lines
146 KiB
Rust
3616 lines
146 KiB
Rust
use codec::entities::Table;
|
||
use glam::Vec3;
|
||
|
||
use crate::command::EntityTransform;
|
||
use crate::entities::common::{ro_prop as ro, square_grip};
|
||
use crate::entities::text_support::{
|
||
layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle,
|
||
};
|
||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
|
||
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
|
||
use crate::scene::model::object::{GripApply, GripDef, PropSection, Property, PropValue};
|
||
use crate::scene::view::transform;
|
||
use crate::scene::model::wire_model::SnapHint;
|
||
use crate::t;
|
||
|
||
/// Largest cell grid (rows × columns) the property editor may grow a
|
||
/// table to. Row/column counts are iteration counts for
|
||
/// `apply_geom_prop`'s grow loops, but `parse_f64` accepts anything up
|
||
/// to `MAX_TYPED_MAGNITUDE` (1e15) — an unclamped request kept pushing
|
||
/// rows/columns until the allocator failed.
|
||
const MAX_TABLE_CELLS: usize = 1_000_000;
|
||
|
||
thread_local! {
|
||
static PROPERTY_CELL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||
static PROPERTY_CELL_ACTIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||
}
|
||
|
||
pub fn set_prop_current_cell(index: usize) {
|
||
PROPERTY_CELL.with(|cell| cell.set(index));
|
||
}
|
||
|
||
pub fn set_prop_current_cell_active(active: bool) {
|
||
PROPERTY_CELL_ACTIVE.with(|cell| cell.set(active));
|
||
}
|
||
|
||
fn prop_current_cell() -> usize {
|
||
PROPERTY_CELL.with(std::cell::Cell::get)
|
||
}
|
||
|
||
fn prop_current_cell_active() -> bool {
|
||
PROPERTY_CELL_ACTIVE.with(std::cell::Cell::get)
|
||
}
|
||
|
||
fn v3(v: &codec::types::Vector3) -> Vec3 {
|
||
Vec3::new(v.x as f32, v.y as f32, v.z as f32)
|
||
}
|
||
|
||
fn table_axes(table: &Table) -> (Vec3, Vec3) {
|
||
let h = v3(&table.horizontal_direction).normalize_or(Vec3::X);
|
||
let normal = v3(&table.normal).normalize_or(Vec3::Z);
|
||
let down = h.cross(normal).normalize_or(Vec3::NEG_Y);
|
||
(h, down)
|
||
}
|
||
|
||
fn merged_owner_and_span(
|
||
table: &Table,
|
||
row: usize,
|
||
column: usize,
|
||
) -> Option<(usize, usize, usize, usize)> {
|
||
for range in &table.merged_ranges {
|
||
if range.contains(row, column) {
|
||
return (row == range.top_row && column == range.left_col).then_some((
|
||
range.top_row,
|
||
range.left_col,
|
||
range.bottom_row.min(table.rows.len().saturating_sub(1)),
|
||
range.right_col.min(table.columns.len().saturating_sub(1)),
|
||
));
|
||
}
|
||
}
|
||
|
||
for (owner_row, table_row) in table.rows.iter().enumerate() {
|
||
for (owner_column, cell) in table_row.cells.iter().enumerate() {
|
||
let row_end = owner_row
|
||
.saturating_add(cell.merge_height.max(1) as usize - 1)
|
||
.min(table.rows.len().saturating_sub(1));
|
||
let column_end = owner_column
|
||
.saturating_add(cell.merge_width.max(1) as usize - 1)
|
||
.min(table.columns.len().saturating_sub(1));
|
||
if row >= owner_row
|
||
&& row <= row_end
|
||
&& column >= owner_column
|
||
&& column <= column_end
|
||
{
|
||
return (row == owner_row && column == owner_column).then_some((
|
||
owner_row,
|
||
owner_column,
|
||
row_end,
|
||
column_end,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
Some((row, column, row, column))
|
||
}
|
||
|
||
pub(crate) fn style_for_property<'a>(
|
||
table: &'a Table,
|
||
row: &'a codec::entities::table::TableRow,
|
||
column: usize,
|
||
cell: &'a codec::entities::table::TableCell,
|
||
property: codec::entities::table::CellStylePropertyFlags,
|
||
) -> Option<&'a codec::entities::table::CellStyle> {
|
||
// The cell's own overrides, as either layout (DXF or binary) states them.
|
||
if let Some(style) = cell.style.as_ref().filter(|style| style.sets(property)) {
|
||
return Some(style);
|
||
}
|
||
let column_style = table
|
||
.columns
|
||
.get(column)
|
||
.and_then(|column| column.style.as_ref());
|
||
for style in [
|
||
row.style.as_ref(),
|
||
column_style,
|
||
table.base_style.as_ref(),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
if style.property_flags.contains(property) {
|
||
return Some(style);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// A cell's text height: the content's own format when it overrides it,
|
||
/// else the cell, row, column or table style that sets it, else the
|
||
/// content's value.
|
||
fn cell_text_height(
|
||
table: &Table,
|
||
row: &codec::entities::table::TableRow,
|
||
column: usize,
|
||
cell: &codec::entities::table::TableCell,
|
||
content: &codec::entities::table::CellContent,
|
||
) -> Option<f64> {
|
||
use codec::entities::table::CellStylePropertyFlags as P;
|
||
let own = content.sets(P::TEXT_HEIGHT);
|
||
own.then_some(content.text_height)
|
||
.or_else(|| style_for_property(table, row, column, cell, P::TEXT_HEIGHT).map(|s| s.text_height))
|
||
.or(Some(content.text_height))
|
||
.filter(|h| *h > 1e-6)
|
||
}
|
||
|
||
/// A cell's text style: the content's own, else the cell, row, column or
|
||
/// table style that sets it (by handle, or by name for a cell override).
|
||
fn cell_text_style(
|
||
document: &codec::CadDocument,
|
||
table: &Table,
|
||
row: &codec::entities::table::TableRow,
|
||
column: usize,
|
||
cell: &codec::entities::table::TableCell,
|
||
content: Option<&codec::entities::table::CellContent>,
|
||
) -> Option<codec::Handle> {
|
||
use codec::entities::table::CellStylePropertyFlags as P;
|
||
content.and_then(|c| c.text_style_handle).or_else(|| {
|
||
let style = style_for_property(table, row, column, cell, P::TEXT_STYLE)?;
|
||
style.text_style_handle.or_else(|| {
|
||
let name = style.text_style_name.trim();
|
||
document
|
||
.text_styles
|
||
.iter()
|
||
.find(|s| !name.is_empty() && s.name.eq_ignore_ascii_case(name))
|
||
.map(|s| s.handle)
|
||
})
|
||
})
|
||
}
|
||
|
||
fn style_for_border<'a>(
|
||
table: &'a Table,
|
||
row: &'a codec::entities::table::TableRow,
|
||
column: usize,
|
||
cell: &'a codec::entities::table::TableCell,
|
||
edge: codec::entities::table::CellEdgeFlags,
|
||
) -> Option<&'a codec::entities::table::CellStyle> {
|
||
let column_style = table
|
||
.columns
|
||
.get(column)
|
||
.and_then(|column| column.style.as_ref());
|
||
[
|
||
cell.style.as_ref(),
|
||
row.style.as_ref(),
|
||
column_style,
|
||
table.base_style.as_ref(),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.find(|style| style.applied_border_edges.contains(edge))
|
||
}
|
||
|
||
pub(crate) fn resolved_title_suppressed(
|
||
table: &Table,
|
||
table_style: Option<&codec::objects::TableStyle>,
|
||
) -> bool {
|
||
table
|
||
.legacy_style_override
|
||
.as_ref()
|
||
.and_then(|style| style.title_suppressed)
|
||
.or_else(|| table_style.map(|style| style.title_suppressed))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
pub(crate) fn resolved_header_suppressed(
|
||
table: &Table,
|
||
table_style: Option<&codec::objects::TableStyle>,
|
||
) -> bool {
|
||
table
|
||
.legacy_style_override
|
||
.as_ref()
|
||
.and_then(|style| style.header_suppressed)
|
||
.or_else(|| table_style.map(|style| style.header_suppressed))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
pub(crate) fn resolved_flow_up(
|
||
table: &Table,
|
||
table_style: Option<&codec::objects::TableStyle>,
|
||
) -> bool {
|
||
use codec::entities::table::CellStylePropertyFlags;
|
||
|
||
table
|
||
.legacy_style_override
|
||
.as_ref()
|
||
.and_then(|style| style.flow_direction)
|
||
.map(|flow| flow != 0)
|
||
.or_else(|| {
|
||
table.base_style.as_ref().and_then(|style| {
|
||
style
|
||
.property_flags
|
||
.contains(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP)
|
||
.then_some(true)
|
||
})
|
||
})
|
||
.unwrap_or_else(|| {
|
||
matches!(
|
||
table_style.map(|style| style.flow_direction),
|
||
Some(codec::objects::TableFlowDirection::Up)
|
||
)
|
||
})
|
||
}
|
||
|
||
pub(crate) fn resolved_table_margins(
|
||
table: &Table,
|
||
table_style: Option<&codec::objects::TableStyle>,
|
||
) -> (f64, f64) {
|
||
let horizontal = table
|
||
.legacy_style_override
|
||
.as_ref()
|
||
.and_then(|style| style.horizontal_cell_margin)
|
||
.or_else(|| table_style.map(|style| style.horizontal_margin))
|
||
.unwrap_or(0.0);
|
||
let vertical = table
|
||
.legacy_style_override
|
||
.as_ref()
|
||
.and_then(|style| style.vertical_cell_margin)
|
||
.or_else(|| table_style.map(|style| style.vertical_margin))
|
||
.unwrap_or(0.0);
|
||
(horizontal, vertical)
|
||
}
|
||
|
||
fn table_offsets(table: &Table, scale: f32) -> (Vec<f32>, Vec<f32>) {
|
||
let mut columns = Vec::with_capacity(table.columns.len() + 1);
|
||
columns.push(0.0);
|
||
for column in &table.columns {
|
||
columns.push(columns.last().copied().unwrap_or(0.0) + column.width as f32 * scale);
|
||
}
|
||
|
||
let mut rows = Vec::with_capacity(table.rows.len() + 1);
|
||
rows.push(0.0);
|
||
for row in &table.rows {
|
||
rows.push(rows.last().copied().unwrap_or(0.0) + row.height as f32 * scale);
|
||
}
|
||
(columns, rows)
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct TableBreakSegment {
|
||
start_row: usize,
|
||
end_row: usize,
|
||
origin: Vec3,
|
||
}
|
||
|
||
fn table_break_segments(
|
||
table: &Table,
|
||
h: Vec3,
|
||
down: Vec3,
|
||
row_offsets: &[f32],
|
||
scale: f32,
|
||
) -> Vec<TableBreakSegment> {
|
||
use codec::entities::table::BreakOptionFlags;
|
||
|
||
let insertion = v3(&table.insertion_point);
|
||
if table.rows.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
if !table.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) {
|
||
return vec![TableBreakSegment {
|
||
start_row: 0,
|
||
end_row: table.rows.len() - 1,
|
||
origin: insertion,
|
||
}];
|
||
}
|
||
|
||
let offset_to_world = |offset: &codec::types::Vector3| insertion + v3(offset);
|
||
let mut cached: Vec<_> = table
|
||
.break_ranges
|
||
.iter()
|
||
.filter_map(|range| {
|
||
let start = range.start_row.max(0) as usize;
|
||
let end = range.end_row.max(0) as usize;
|
||
(start <= end && end < table.rows.len()).then(|| TableBreakSegment {
|
||
start_row: start,
|
||
end_row: end,
|
||
origin: {
|
||
let position = offset_to_world(&range.position);
|
||
if position.is_finite() {
|
||
position
|
||
} else {
|
||
insertion
|
||
}
|
||
},
|
||
})
|
||
})
|
||
.collect();
|
||
cached.sort_by_key(|segment| segment.start_row);
|
||
let cached_complete = cached.first().is_some_and(|segment| segment.start_row == 0)
|
||
&& cached
|
||
.last()
|
||
.is_some_and(|segment| segment.end_row + 1 == table.rows.len())
|
||
&& cached
|
||
.windows(2)
|
||
.all(|pair| pair[0].end_row + 1 == pair[1].start_row);
|
||
if cached_complete {
|
||
return cached;
|
||
}
|
||
|
||
let mut segments = Vec::new();
|
||
let mut start_row = 0usize;
|
||
let mut segment = 0usize;
|
||
while start_row < table.rows.len() {
|
||
let manual_heights = table
|
||
.break_options
|
||
.contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS);
|
||
let max_height = table
|
||
.break_data
|
||
.get(if manual_heights { segment } else { 0 })
|
||
.map(|data| data.height as f32 * scale)
|
||
.filter(|height| *height > 1e-6)
|
||
.unwrap_or(f32::INFINITY);
|
||
let start_offset = row_offsets.get(start_row).copied().unwrap_or(0.0);
|
||
let mut end_row = start_row;
|
||
while end_row + 1 < table.rows.len()
|
||
&& row_offsets[end_row + 2] - start_offset <= max_height
|
||
{
|
||
end_row += 1;
|
||
}
|
||
let manual_positions = table
|
||
.break_options
|
||
.contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS);
|
||
let manual_origin = manual_positions
|
||
.then(|| table.break_data.get(segment))
|
||
.flatten()
|
||
.map(|data| offset_to_world(&data.position))
|
||
.filter(|position| position.is_finite());
|
||
let origin = manual_origin.unwrap_or_else(|| {
|
||
let spacing = table.break_spacing as f32 * scale;
|
||
let horizontal_step = table.total_width() as f32 * scale + spacing;
|
||
let vertical_step = if max_height.is_finite() {
|
||
max_height + spacing
|
||
} else {
|
||
table.total_height() as f32 * scale + spacing
|
||
};
|
||
match table.break_flow_direction {
|
||
codec::entities::table::BreakFlowDirection::Left => {
|
||
insertion - h * segment as f32 * horizontal_step
|
||
}
|
||
codec::entities::table::BreakFlowDirection::Vertical => {
|
||
insertion + down * segment as f32 * vertical_step
|
||
}
|
||
codec::entities::table::BreakFlowDirection::Right => {
|
||
insertion + h * segment as f32 * horizontal_step
|
||
}
|
||
}
|
||
});
|
||
segments.push(TableBreakSegment {
|
||
start_row,
|
||
end_row,
|
||
origin,
|
||
});
|
||
start_row = end_row.saturating_add(1);
|
||
segment = segment.saturating_add(1);
|
||
}
|
||
|
||
segments
|
||
}
|
||
|
||
fn break_frame_for_row(
|
||
table: &Table,
|
||
row: usize,
|
||
h: Vec3,
|
||
down: Vec3,
|
||
row_offsets: &[f32],
|
||
scale: f32,
|
||
) -> (Vec3, f32) {
|
||
let insertion = v3(&table.insertion_point);
|
||
if let Some(segment) = table_break_segments(table, h, down, row_offsets, scale)
|
||
.into_iter()
|
||
.find(|segment| row >= segment.start_row && row <= segment.end_row)
|
||
{
|
||
return (
|
||
segment.origin,
|
||
row_offsets.get(row).copied().unwrap_or(0.0)
|
||
- row_offsets
|
||
.get(segment.start_row)
|
||
.copied()
|
||
.unwrap_or(0.0),
|
||
);
|
||
}
|
||
|
||
(insertion, row_offsets.get(row).copied().unwrap_or(0.0))
|
||
}
|
||
|
||
fn break_frames_for_row(
|
||
table: &Table,
|
||
row: usize,
|
||
h: Vec3,
|
||
down: Vec3,
|
||
row_offsets: &[f32],
|
||
scale: f32,
|
||
top_label_rows: usize,
|
||
bottom_label_rows: usize,
|
||
) -> Vec<(Vec3, f32)> {
|
||
use codec::entities::table::BreakOptionFlags;
|
||
|
||
let segments = table_break_segments(table, h, down, row_offsets, scale);
|
||
let Some((segment_index, segment)) = segments
|
||
.iter()
|
||
.enumerate()
|
||
.find(|(_, segment)| row >= segment.start_row && row <= segment.end_row)
|
||
else {
|
||
return vec![break_frame_for_row(table, row, h, down, row_offsets, scale)];
|
||
};
|
||
let repeat_top = table
|
||
.break_options
|
||
.contains(BreakOptionFlags::REPEAT_TOP_LABELS)
|
||
&& top_label_rows > 0;
|
||
let repeat_bottom = table
|
||
.break_options
|
||
.contains(BreakOptionFlags::REPEAT_BOTTOM_LABELS)
|
||
&& bottom_label_rows > 0;
|
||
let top_label_rows = top_label_rows.min(table.rows.len());
|
||
let bottom_label_rows = bottom_label_rows.min(table.rows.len());
|
||
let bottom_start = table.rows.len().saturating_sub(bottom_label_rows);
|
||
let top_height = row_offsets
|
||
.get(top_label_rows)
|
||
.copied()
|
||
.unwrap_or(0.0);
|
||
let mut primary_top = row_offsets.get(row).copied().unwrap_or(0.0)
|
||
- row_offsets
|
||
.get(segment.start_row)
|
||
.copied()
|
||
.unwrap_or(0.0);
|
||
if repeat_top && segment_index > 0 {
|
||
primary_top += top_height;
|
||
}
|
||
let mut frames = vec![(segment.origin, primary_top)];
|
||
|
||
if repeat_top && row < top_label_rows {
|
||
for repeated_segment in segments.iter().skip(1) {
|
||
frames.push((
|
||
repeated_segment.origin,
|
||
row_offsets.get(row).copied().unwrap_or(0.0),
|
||
));
|
||
}
|
||
}
|
||
if repeat_bottom && row >= bottom_start {
|
||
let label_offset = row_offsets.get(row).copied().unwrap_or(0.0)
|
||
- row_offsets.get(bottom_start).copied().unwrap_or(0.0);
|
||
for (index, repeated_segment) in segments
|
||
.iter()
|
||
.enumerate()
|
||
.take(segments.len().saturating_sub(1))
|
||
{
|
||
let content_height = row_offsets
|
||
.get(repeated_segment.end_row + 1)
|
||
.copied()
|
||
.unwrap_or(0.0)
|
||
- row_offsets
|
||
.get(repeated_segment.start_row)
|
||
.copied()
|
||
.unwrap_or(0.0);
|
||
let repeated_top = content_height
|
||
+ if repeat_top && index > 0 {
|
||
top_height
|
||
} else {
|
||
0.0
|
||
}
|
||
+ label_offset;
|
||
frames.push((repeated_segment.origin, repeated_top));
|
||
}
|
||
}
|
||
frames
|
||
}
|
||
|
||
fn format_cell_value(value: &codec::entities::table::CellValue) -> String {
|
||
let display = value.display();
|
||
if !display.is_empty() {
|
||
return display.to_string();
|
||
}
|
||
use codec::entities::table::CellValueType;
|
||
match value.value_type {
|
||
CellValueType::Long => format!("{}", value.numeric_value as i64),
|
||
CellValueType::Double | CellValueType::Date => format!("{}", value.numeric_value),
|
||
CellValueType::Point2D => {
|
||
format!("{}, {}", value.point_value.x, value.point_value.y)
|
||
}
|
||
CellValueType::Point3D => format!(
|
||
"{}, {}, {}",
|
||
value.point_value.x, value.point_value.y, value.point_value.z
|
||
),
|
||
CellValueType::Handle => value
|
||
.handle_value
|
||
.map(|handle| format!("{:X}", handle.value()))
|
||
.unwrap_or_default(),
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
|
||
fn table_cell_reference(reference: &str) -> Option<(usize, usize)> {
|
||
let reference = reference.trim();
|
||
let split = reference
|
||
.char_indices()
|
||
.find(|(_, ch)| ch.is_ascii_digit())?
|
||
.0;
|
||
let (letters, digits) = reference.split_at(split);
|
||
if letters.is_empty() || digits.is_empty() {
|
||
return None;
|
||
}
|
||
let mut column = 0usize;
|
||
for ch in letters.chars() {
|
||
if !ch.is_ascii_alphabetic() {
|
||
return None;
|
||
}
|
||
column = column
|
||
.checked_mul(26)?
|
||
.checked_add(ch.to_ascii_uppercase() as usize - 'A' as usize + 1)?;
|
||
}
|
||
let row = digits.parse::<usize>().ok()?.checked_sub(1)?;
|
||
Some((row, column.checked_sub(1)?))
|
||
}
|
||
|
||
fn evaluate_table_formula(table: &Table, expression: &str) -> Option<String> {
|
||
let body = expression.trim().strip_prefix('=')?.trim();
|
||
if let Some((row, column)) = table_cell_reference(body) {
|
||
return table.cell(row, column).map(|cell| cell.text_value().to_string());
|
||
}
|
||
let open = body.find('(')?;
|
||
let close = body.rfind(')')?;
|
||
let function = body[..open].trim().to_ascii_uppercase();
|
||
// `find('(')` scans forward while `rfind(')')` scans backward, so a
|
||
// malformed body like `)(` orders them reversed; the safe `get` yields
|
||
// None (fall back to the literal text) instead of a slice panic.
|
||
let range = body.get(open + 1..close)?;
|
||
let (first, last) = range.split_once(':').unwrap_or((range, range));
|
||
let (r1, c1) = table_cell_reference(first)?;
|
||
let (r2, c2) = table_cell_reference(last)?;
|
||
let mut values = Vec::new();
|
||
// A range can name any `usize` (`A1:A18446744073709551615`): `cell()`
|
||
// returns None outside the table, so clamping the walk to the table's
|
||
// own rows/columns collects exactly the same values — every skipped
|
||
// cell would have been None — while keeping an unbounded walk off the
|
||
// render path (1.8e19 iterations of `..=`).
|
||
let row_end = r1.max(r2).min(table.rows.len().saturating_sub(1));
|
||
let col_end = c1.max(c2).min(table.columns.len().saturating_sub(1));
|
||
for row in r1.min(r2)..=row_end {
|
||
for column in c1.min(c2)..=col_end {
|
||
if let Some(value) = table
|
||
.cell(row, column)
|
||
.and_then(|cell| cell.text_value().trim().parse::<f64>().ok())
|
||
{
|
||
values.push(value);
|
||
}
|
||
}
|
||
}
|
||
match function.as_str() {
|
||
"SUM" => Some(values.iter().sum::<f64>().to_string()),
|
||
"AVERAGE" | "AVG" if !values.is_empty() => {
|
||
Some((values.iter().sum::<f64>() / values.len() as f64).to_string())
|
||
}
|
||
"COUNT" => Some(values.len().to_string()),
|
||
"MIN" if !values.is_empty() => {
|
||
Some(values.into_iter().fold(f64::INFINITY, f64::min).to_string())
|
||
}
|
||
"MAX" if !values.is_empty() => Some(
|
||
values
|
||
.into_iter()
|
||
.fold(f64::NEG_INFINITY, f64::max)
|
||
.to_string(),
|
||
),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn fallback_content_centers(
|
||
bounds: [f32; 4],
|
||
sizes: &[(f32, f32)],
|
||
layout: codec::entities::table::ContentLayoutFlags,
|
||
alignment: i32,
|
||
horizontal_spacing: f32,
|
||
vertical_spacing: f32,
|
||
) -> Vec<(f32, f32)> {
|
||
if sizes.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let [left, top, right, bottom] = bounds;
|
||
let inner_width = (right - left).max(0.0);
|
||
let horiz = ((alignment - 1).rem_euclid(3)) + 1;
|
||
let vert = ((alignment - 1) / 3) + 1;
|
||
let mut rows: Vec<Vec<usize>> = Vec::new();
|
||
if layout.contains(
|
||
codec::entities::table::ContentLayoutFlags::STACKED_VERTICAL,
|
||
) {
|
||
for index in 0..sizes.len() {
|
||
rows.push(vec![index]);
|
||
}
|
||
} else if layout.contains(
|
||
codec::entities::table::ContentLayoutFlags::STACKED_HORIZONTAL,
|
||
) {
|
||
rows.push((0..sizes.len()).collect());
|
||
} else {
|
||
let mut row = Vec::new();
|
||
let mut width = 0.0f32;
|
||
for (index, (item_width, _)) in sizes.iter().copied().enumerate() {
|
||
let next = if row.is_empty() {
|
||
item_width
|
||
} else {
|
||
width + horizontal_spacing + item_width
|
||
};
|
||
if !row.is_empty() && next > inner_width {
|
||
rows.push(std::mem::take(&mut row));
|
||
width = item_width;
|
||
} else {
|
||
width = next;
|
||
}
|
||
row.push(index);
|
||
}
|
||
if !row.is_empty() {
|
||
rows.push(row);
|
||
}
|
||
}
|
||
|
||
let row_heights: Vec<f32> = rows
|
||
.iter()
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|&index| sizes[index].1)
|
||
.fold(0.0f32, f32::max)
|
||
})
|
||
.collect();
|
||
let total_height = row_heights.iter().sum::<f32>()
|
||
+ vertical_spacing * rows.len().saturating_sub(1) as f32;
|
||
let mut y = match vert {
|
||
1 => top,
|
||
3 => bottom - total_height,
|
||
_ => (top + bottom - total_height) * 0.5,
|
||
};
|
||
let mut result = vec![(0.0f32, 0.0f32); sizes.len()];
|
||
for (row_index, row) in rows.iter().enumerate() {
|
||
let row_width = row.iter().map(|&index| sizes[index].0).sum::<f32>()
|
||
+ horizontal_spacing * row.len().saturating_sub(1) as f32;
|
||
let mut x = match horiz {
|
||
1 => left,
|
||
3 => right - row_width,
|
||
_ => (left + right - row_width) * 0.5,
|
||
};
|
||
for &index in row {
|
||
let (width, height) = sizes[index];
|
||
result[index] =
|
||
(x + width * 0.5, y + (row_heights[row_index] - height) * 0.5 + height * 0.5);
|
||
x += width + horizontal_spacing;
|
||
}
|
||
y += row_heights[row_index] + vertical_spacing;
|
||
}
|
||
result
|
||
}
|
||
|
||
fn content_display_value(
|
||
document: &codec::CadDocument,
|
||
table: &Table,
|
||
content: &codec::entities::table::CellContent,
|
||
) -> String {
|
||
if let Some(handle) = content.field_handle {
|
||
if let Some(value) =
|
||
crate::entities::field::cell_text(document, handle, table.common.handle)
|
||
{
|
||
return value;
|
||
}
|
||
if let Some(codec::objects::ObjectType::Field(field)) = document.objects.get(&handle) {
|
||
if !field.value_string.is_empty() {
|
||
return field.value_string.clone();
|
||
}
|
||
let value = format_cell_value(&field.value);
|
||
if !value.is_empty() {
|
||
return value;
|
||
}
|
||
}
|
||
}
|
||
let value = format_cell_value(&content.value);
|
||
evaluate_table_formula(table, &value).unwrap_or(value)
|
||
}
|
||
|
||
fn resolved_content_geometry(
|
||
document: &codec::CadDocument,
|
||
table: &Table,
|
||
row: usize,
|
||
column: usize,
|
||
cell: &codec::entities::table::TableCell,
|
||
content_index: usize,
|
||
) -> Option<codec::entities::table::CellContentGeometry> {
|
||
if let Some(geometry) = cell
|
||
.contents
|
||
.get(content_index)
|
||
.and_then(|content| content.geometry.clone())
|
||
.or_else(|| cell.geometries.get(content_index).cloned())
|
||
.or_else(|| (content_index == 0).then(|| cell.geometry.clone()).flatten())
|
||
{
|
||
return Some(geometry);
|
||
}
|
||
|
||
let handle = cell.geometry_handle?;
|
||
let flat_index = row
|
||
.saturating_mul(table.columns.len())
|
||
.saturating_add(column);
|
||
if let Some(codec::objects::ObjectType::DataObject(object)) =
|
||
document.objects.get(&handle)
|
||
{
|
||
if let codec::objects::DataObjectData::TableGeometry(geometry) =
|
||
&object.data
|
||
{
|
||
return geometry
|
||
.cells
|
||
.get(flat_index)
|
||
.and_then(|cell| cell.geometry.get(content_index))
|
||
.cloned();
|
||
}
|
||
}
|
||
document.objects.values().find_map(|object| {
|
||
let codec::objects::ObjectType::DataObject(object) = object else {
|
||
return None;
|
||
};
|
||
let codec::objects::DataObjectData::TableGeometry(geometry) =
|
||
&object.data
|
||
else {
|
||
return None;
|
||
};
|
||
geometry
|
||
.cells
|
||
.iter()
|
||
.find(|geometry_cell| geometry_cell.table_geometry == handle)
|
||
.and_then(|geometry_cell| geometry_cell.geometry.get(content_index))
|
||
.cloned()
|
||
})
|
||
}
|
||
|
||
pub(crate) fn block_cell_inserts(
|
||
table: &Table,
|
||
document: &codec::CadDocument,
|
||
anno_scale: f32,
|
||
) -> Vec<codec::entities::Insert> {
|
||
use codec::entities::table::ContentLayoutFlags;
|
||
use codec::entities::{AttributeEntity, EntityType, Insert};
|
||
use codec::types::Vector3;
|
||
|
||
if table.rows.is_empty() || table.columns.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let (h, down) = table_axes(table);
|
||
let table_style = table.table_style_handle.and_then(|handle| {
|
||
document.objects.get(&handle).and_then(|object| match object {
|
||
codec::objects::ObjectType::TableStyle(style) => Some(style),
|
||
_ => None,
|
||
})
|
||
});
|
||
let flow = if resolved_flow_up(table, table_style) {
|
||
-down
|
||
} else {
|
||
down
|
||
};
|
||
let (column_offsets, row_offsets) = table_offsets(table, anno_scale);
|
||
let table_rotation = table
|
||
.horizontal_direction
|
||
.y
|
||
.atan2(table.horizontal_direction.x);
|
||
let mut inserts = Vec::new();
|
||
|
||
for (row_index, row) in table.rows.iter().enumerate() {
|
||
for (column_index, cell) in row.cells.iter().enumerate() {
|
||
let Some((_, _, row_end, column_end)) =
|
||
merged_owner_and_span(table, row_index, column_index)
|
||
else {
|
||
continue;
|
||
};
|
||
let block_contents: Vec<_> = cell
|
||
.contents
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, content)| content.block_handle.is_some())
|
||
.collect();
|
||
if block_contents.is_empty() {
|
||
continue;
|
||
}
|
||
let (origin, row_top) = break_frame_for_row(
|
||
table,
|
||
row_index,
|
||
h,
|
||
flow,
|
||
&row_offsets,
|
||
anno_scale,
|
||
);
|
||
let row_bottom = row_top
|
||
+ row_offsets
|
||
.get(row_end + 1)
|
||
.copied()
|
||
.unwrap_or(row_offsets[row_index])
|
||
- row_offsets[row_index];
|
||
let column_left = column_offsets[column_index];
|
||
let column_right = column_offsets
|
||
.get(column_end + 1)
|
||
.copied()
|
||
.unwrap_or(column_left);
|
||
let layout_style = style_for_property(
|
||
table,
|
||
row,
|
||
column_index,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::CONTENT_LAYOUT,
|
||
);
|
||
let layout = layout_style
|
||
.map(|style| style.layout_flags)
|
||
.unwrap_or(ContentLayoutFlags::FLOW);
|
||
let count = block_contents.len() as f32;
|
||
|
||
for (slot_index, (content_index, content)) in
|
||
block_contents.into_iter().enumerate()
|
||
{
|
||
let Some(block_handle) = content.block_handle else {
|
||
continue;
|
||
};
|
||
let Some(record) = document
|
||
.block_records
|
||
.iter()
|
||
.find(|record| record.handle == block_handle)
|
||
else {
|
||
continue;
|
||
};
|
||
let mut x = (column_left + column_right) * 0.5;
|
||
let mut y = (row_top + row_bottom) * 0.5;
|
||
let mut z = 0.0f32;
|
||
if let Some(geometry) = resolved_content_geometry(
|
||
document,
|
||
table,
|
||
row_index,
|
||
column_index,
|
||
cell,
|
||
content_index,
|
||
) {
|
||
x = column_left
|
||
+ geometry.distance_to_center.x as f32 * anno_scale;
|
||
y = row_top
|
||
- geometry.distance_to_center.y as f32 * anno_scale;
|
||
z = geometry.distance_to_center.z as f32 * anno_scale;
|
||
} else if count > 1.0 {
|
||
let index = slot_index as f32;
|
||
if layout.contains(ContentLayoutFlags::STACKED_VERTICAL) {
|
||
y = row_top + (index + 0.5) * (row_bottom - row_top) / count;
|
||
} else {
|
||
x = column_left + (index + 0.5) * (column_right - column_left) / count;
|
||
}
|
||
}
|
||
let position =
|
||
origin + h * x + flow * y + v3(&table.normal).normalize_or(Vec3::Z) * z;
|
||
let scale_style = style_for_property(
|
||
table,
|
||
row,
|
||
column_index,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::BLOCK_SCALE,
|
||
);
|
||
let style_scale = scale_style
|
||
.map(|style| style.scale)
|
||
.filter(|scale| scale.abs() > 1e-9)
|
||
.unwrap_or(1.0);
|
||
let content_scale = if content.format_property_flags
|
||
& codec::entities::table::CellStylePropertyFlags::BLOCK_SCALE.bits() as i32
|
||
!= 0
|
||
&& content.scale.abs() > 1e-9
|
||
{
|
||
content.scale
|
||
} else if cell.block_scale.abs() > 1e-9 {
|
||
cell.block_scale
|
||
} else {
|
||
1.0
|
||
};
|
||
let mut scale = if content.format_property_flags
|
||
& codec::entities::table::CellStylePropertyFlags::BLOCK_SCALE.bits() as i32
|
||
!= 0
|
||
{
|
||
content_scale
|
||
} else {
|
||
style_scale.max(content_scale)
|
||
} * anno_scale as f64;
|
||
let auto_scale = cell.auto_fit
|
||
|| style_for_property(
|
||
table,
|
||
row,
|
||
column_index,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::AUTO_SCALE,
|
||
)
|
||
.is_some_and(|style| {
|
||
style
|
||
.property_flags
|
||
.contains(codec::entities::table::CellStylePropertyFlags::AUTO_SCALE)
|
||
});
|
||
let mut block_min =
|
||
Vector3::new(f64::MAX, f64::MAX, f64::MAX);
|
||
let mut block_max =
|
||
Vector3::new(f64::MIN, f64::MIN, f64::MIN);
|
||
let mut has_block_bounds = false;
|
||
for &handle in &record.entity_handles {
|
||
let Some(entity) = document.get_entity(handle) else {
|
||
continue;
|
||
};
|
||
let bounds = entity.as_entity().bounding_box();
|
||
if bounds.min.x.is_finite()
|
||
&& bounds.min.y.is_finite()
|
||
&& bounds.max.x.is_finite()
|
||
&& bounds.max.y.is_finite()
|
||
{
|
||
block_min.x = block_min.x.min(bounds.min.x);
|
||
block_min.y = block_min.y.min(bounds.min.y);
|
||
block_min.z = block_min.z.min(bounds.min.z);
|
||
block_max.x = block_max.x.max(bounds.max.x);
|
||
block_max.y = block_max.y.max(bounds.max.y);
|
||
block_max.z = block_max.z.max(bounds.max.z);
|
||
has_block_bounds = true;
|
||
}
|
||
}
|
||
if auto_scale && has_block_bounds {
|
||
let min = block_min;
|
||
let max = block_max;
|
||
let width = (max.x - min.x).abs();
|
||
let height = (max.y - min.y).abs();
|
||
let margin_left = style_for_property(
|
||
table,
|
||
row,
|
||
column_index,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_LEFT,
|
||
)
|
||
.map(|style| style.margin_left)
|
||
.unwrap_or(0.0);
|
||
let margin_right = style_for_property(
|
||
table,
|
||
row,
|
||
column_index,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_RIGHT,
|
||
)
|
||
.map(|style| style.margin_right)
|
||
.unwrap_or(0.0);
|
||
let margin_top = style_for_property(
|
||
table,
|
||
row,
|
||
column_index,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_TOP,
|
||
)
|
||
.map(|style| style.margin_top)
|
||
.unwrap_or(0.0);
|
||
let margin_bottom = style_for_property(
|
||
table,
|
||
row,
|
||
column_index,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_BOTTOM,
|
||
)
|
||
.map(|style| style.margin_bottom)
|
||
.unwrap_or(0.0);
|
||
let fit_x = ((column_right - column_left) as f64
|
||
- (margin_left + margin_right) * anno_scale as f64)
|
||
.max(0.0)
|
||
/ width.max(1e-9);
|
||
let fit_y = ((row_bottom - row_top) as f64
|
||
- (margin_top + margin_bottom) * anno_scale as f64)
|
||
.max(0.0)
|
||
/ height.max(1e-9);
|
||
scale *= fit_x.min(fit_y);
|
||
}
|
||
let rotation_style = style_for_property(
|
||
table,
|
||
row,
|
||
column_index,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::ROTATION,
|
||
);
|
||
let content_rotation_explicit = content.format_property_flags
|
||
& codec::entities::table::CellStylePropertyFlags::ROTATION.bits() as i32
|
||
!= 0;
|
||
let content_rotation = if content_rotation_explicit {
|
||
content.rotation
|
||
} else {
|
||
rotation_style
|
||
.map(|style| style.rotation)
|
||
.unwrap_or(cell.rotation)
|
||
};
|
||
let rotation = table_rotation + content_rotation;
|
||
let mut insert = Insert::new(
|
||
record.name.clone(),
|
||
Vector3::new(position.x as f64, position.y as f64, position.z as f64),
|
||
);
|
||
insert.common = table.common.clone();
|
||
insert.normal = table.normal;
|
||
insert.rotation = rotation;
|
||
insert.set_x_scale(scale);
|
||
insert.set_y_scale(scale);
|
||
insert.set_z_scale(scale);
|
||
let local_anchor = if has_block_bounds {
|
||
(block_min + block_max) * 0.5
|
||
} else {
|
||
crate::scene::render_graph::block_base_point(document, &record.name)
|
||
};
|
||
let transformed_anchor = crate::scene::render_graph::insert_transform(
|
||
document,
|
||
&insert,
|
||
)
|
||
.apply(local_anchor);
|
||
insert.insert_point = insert.insert_point
|
||
+ Vector3::new(
|
||
position.x as f64 - transformed_anchor.x,
|
||
position.y as f64 - transformed_anchor.y,
|
||
position.z as f64 - transformed_anchor.z,
|
||
);
|
||
let attribute_definitions: Vec<_> = record
|
||
.entity_handles
|
||
.iter()
|
||
.filter_map(|handle| match document.get_entity(*handle) {
|
||
Some(EntityType::AttributeDefinition(definition)) => {
|
||
Some(definition)
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
for attribute in &content.attributes {
|
||
let definition = match document
|
||
.get_entity(attribute.definition_handle)
|
||
{
|
||
Some(EntityType::AttributeDefinition(definition)) => {
|
||
Some(definition)
|
||
}
|
||
_ => attribute_definitions
|
||
.get(attribute.index.max(0) as usize)
|
||
.copied(),
|
||
};
|
||
let Some(definition) = definition else {
|
||
continue;
|
||
};
|
||
let mut entity =
|
||
AttributeEntity::from_definition(definition, Some(attribute.value.clone()));
|
||
codec::Entity::apply_transform(
|
||
&mut entity,
|
||
&crate::scene::render_graph::insert_transform(document, &insert),
|
||
);
|
||
entity.common.handle = table.common.handle;
|
||
insert.attributes.push(entity);
|
||
}
|
||
inserts.push(insert);
|
||
}
|
||
}
|
||
}
|
||
inserts
|
||
}
|
||
|
||
impl RenderConvertible for Table {
|
||
fn to_render(&self, document: &codec::CadDocument) -> Option<RenderEntity> {
|
||
if self.rows.is_empty() || self.columns.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// Lay the table out in a local frame with the origin at zero. The world
|
||
// insertion point is added back as f64 at the widening step below, so
|
||
// large coordinates (UTM etc.) keep full precision instead of snapping
|
||
// onto the coarse f32 grid — which would collide cell-text baselines and
|
||
// overflow the integer border-dedup keys.
|
||
let base = [
|
||
self.insertion_point.x,
|
||
self.insertion_point.y,
|
||
self.insertion_point.z,
|
||
];
|
||
let origin = Vec3::ZERO;
|
||
let (h, v_down) = table_axes(self);
|
||
|
||
let col_offsets: Vec<f32> = {
|
||
let mut off = 0.0f32;
|
||
let mut v = vec![0.0f32];
|
||
for col in &self.columns {
|
||
off += col.width as f32;
|
||
v.push(off);
|
||
}
|
||
v
|
||
};
|
||
let total_w = *col_offsets.last().unwrap_or(&0.0);
|
||
|
||
let row_offsets: Vec<f32> = {
|
||
let mut off = 0.0f32;
|
||
let mut v = vec![0.0f32];
|
||
for row in &self.rows {
|
||
off += row.height as f32;
|
||
v.push(off);
|
||
}
|
||
v
|
||
};
|
||
let total_h = *row_offsets.last().unwrap_or(&0.0);
|
||
|
||
let mut pts: Vec<[f32; 3]> = Vec::new();
|
||
let mut tris_pts: Vec<[f32; 3]> = Vec::new();
|
||
|
||
// Per-cell borders. When a cell carries a CellStyle, honour the
|
||
// visibility / `invisible` flag of each of its four borders so
|
||
// hidden borders disappear from the grid. Cells with no style still
|
||
// emit the standard four borders. To avoid drawing each shared edge
|
||
// twice we coalesce the segments by their (start, end) coordinates.
|
||
use rustc_hash::FxHashSet as HashSet;
|
||
let mut emitted: HashSet<(i32, i32, i32, i32)> = HashSet::default();
|
||
let try_add = |a: Vec3,
|
||
b: Vec3,
|
||
vis: bool,
|
||
emitted: &mut HashSet<(i32, i32, i32, i32)>,
|
||
pts: &mut Vec<[f32; 3]>| {
|
||
if !vis {
|
||
return;
|
||
}
|
||
let key = (
|
||
(a.x * 1_000.0) as i32,
|
||
(a.y * 1_000.0) as i32,
|
||
(b.x * 1_000.0) as i32,
|
||
(b.y * 1_000.0) as i32,
|
||
);
|
||
let key_rev = (key.2, key.3, key.0, key.1);
|
||
if emitted.contains(&key) || emitted.contains(&key_rev) {
|
||
return;
|
||
}
|
||
emitted.insert(key);
|
||
if !pts.is_empty() {
|
||
pts.push([f32::NAN; 3]);
|
||
}
|
||
pts.push([a.x, a.y, a.z]);
|
||
pts.push([b.x, b.y, b.z]);
|
||
};
|
||
for (ri, row) in self.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);
|
||
for (ci, cell) in row.cells.iter().enumerate() {
|
||
let col_left = col_offsets[ci];
|
||
let col_right = col_offsets.get(ci + 1).copied().unwrap_or(
|
||
col_left + self.columns.get(ci).map(|c| c.width as f32).unwrap_or(1.0),
|
||
);
|
||
// Default to visible when no style override is present.
|
||
let (top_vis, right_vis, bottom_vis, left_vis) = cell
|
||
.style
|
||
.as_ref()
|
||
.map(|s| {
|
||
(
|
||
!s.top_border.invisible,
|
||
!s.right_border.invisible,
|
||
!s.bottom_border.invisible,
|
||
!s.left_border.invisible,
|
||
)
|
||
})
|
||
.unwrap_or((true, true, true, true));
|
||
let tl = origin + h * col_left + v_down * row_top;
|
||
let tr = origin + h * col_right + v_down * row_top;
|
||
let br_ = origin + h * col_right + v_down * row_bot;
|
||
let bl = origin + h * col_left + v_down * row_bot;
|
||
try_add(tl, tr, top_vis, &mut emitted, &mut pts);
|
||
try_add(tr, br_, right_vis, &mut emitted, &mut pts);
|
||
try_add(bl, br_, bottom_vis, &mut emitted, &mut pts);
|
||
try_add(tl, bl, left_vis, &mut emitted, &mut pts);
|
||
}
|
||
}
|
||
// Suppress unused-variable warnings now that the simple grid-pass
|
||
// is gone — col/row offsets still feed cell drawing below.
|
||
let _ = (total_w, total_h);
|
||
|
||
// Cell text — resolve defaults via TableStyle, then layer per-cell
|
||
// overrides on top. Resolution order (text height, text style, alignment):
|
||
// 1. CellContent.* (per-content explicit override)
|
||
// 2. CellStyle.* (per-cell explicit override)
|
||
// 3. TableStyle.<row_kind>_row_style.* (table-wide default for this row class)
|
||
// 4. compiled-in fallback (0.18 / "txt" / MiddleCenter)
|
||
//
|
||
// Row classification: row 0 is Title (when not suppressed), row 1 is
|
||
// Header (when not suppressed), everything else is Data. The two
|
||
// suppressed flags shift the leading rows down to Data.
|
||
let lookup_style = |h: codec::Handle| -> Option<&codec::tables::TextStyle> {
|
||
document.text_styles.iter().find(|s| s.handle == h)
|
||
};
|
||
let table_style: Option<&codec::objects::TableStyle> =
|
||
self.table_style_handle.and_then(|h| {
|
||
document.objects.get(&h).and_then(|obj| match obj {
|
||
codec::objects::ObjectType::TableStyle(ts) => Some(ts),
|
||
_ => None,
|
||
})
|
||
});
|
||
let title_suppressed = resolved_title_suppressed(self, table_style);
|
||
let header_suppressed = resolved_header_suppressed(self, table_style);
|
||
|
||
let font_for_handle = |handle: Option<codec::Handle>| -> Option<String> {
|
||
handle.and_then(|h| lookup_style(h)).and_then(|s| {
|
||
let mut font_name = if !s.true_type_font.trim().is_empty() {
|
||
s.true_type_font.trim().to_string()
|
||
} else {
|
||
let file = s.font_file.trim();
|
||
if !file.is_empty() {
|
||
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
|
||
let stem = basename.split('.').next().unwrap_or(basename).trim();
|
||
if !stem.is_empty() {
|
||
stem.to_string()
|
||
} else {
|
||
return None;
|
||
}
|
||
} else {
|
||
return None;
|
||
}
|
||
};
|
||
if !crate::scene::text::lff::is_builtin(&font_name) {
|
||
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
|
||
font_name = canonical;
|
||
}
|
||
}
|
||
Some(font_name)
|
||
})
|
||
};
|
||
// Build a ResolvedTextStyle for the cell — needed by the shared MText
|
||
// pipeline so inline `\W`, `\Q`, etc. compose with the style baseline.
|
||
let resolved_style_for_handle =
|
||
|handle: Option<codec::Handle>, font_name: String| -> ResolvedTextStyle {
|
||
let style = handle.and_then(|h| lookup_style(h));
|
||
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),
|
||
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
|
||
}
|
||
};
|
||
|
||
for (ri, row) in self.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;
|
||
|
||
// Pick the appropriate row_style from TableStyle for this row's role.
|
||
let row_style: Option<&codec::objects::RowCellStyle> = table_style.map(|ts| {
|
||
let kind = match (title_suppressed, header_suppressed, ri) {
|
||
(false, _, 0) => 0, // title
|
||
(false, false, 1) => 1, // header
|
||
(true, false, 0) => 1, // header pulled up
|
||
_ => 2, // data
|
||
};
|
||
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 text = cell.text_value();
|
||
if text.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let col_left = col_offsets[ci];
|
||
let col_width = self.columns.get(ci).map(|c| c.width as f32).unwrap_or(1.0);
|
||
let col_right = col_left + col_width;
|
||
|
||
// Resolve text height: content → cell-style → row-style → 0.18.
|
||
let content = cell.contents.first();
|
||
let cell_h = content
|
||
.and_then(|c| cell_text_height(self, row, ci, cell, c))
|
||
.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 margin = cell_h * 0.5_f32;
|
||
|
||
// Resolve text-style handle: content → cell-style → row-style.
|
||
let style_handle = cell_text_style(document, self, row, ci, cell, content)
|
||
.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);
|
||
|
||
// Alignment resolution: cell.style.alignment (1-9) overrides;
|
||
// otherwise fall back to row_style.alignment, then MiddleCenter.
|
||
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; // 1=left, 2=center, 3=right
|
||
let vert = ((align - 1) / 3) + 1; // 1=top, 2=middle, 3=bottom
|
||
|
||
// Position the cell's MText block anchor at the requested
|
||
// alignment corner / midpoint of the cell's content area.
|
||
let (x_offset, attach_h_anchor) = match horiz {
|
||
1 => (col_left + margin, 0.0_f32),
|
||
3 => (col_right - margin, 1.0_f32),
|
||
_ => (col_left + col_width * 0.5, 0.5_f32),
|
||
};
|
||
let (y_offset, v_anchor) = match vert {
|
||
1 => (row_top + margin, MTextVAnchor::Top),
|
||
3 => (row_bot - margin, MTextVAnchor::Bottom),
|
||
_ => (row_mid, MTextVAnchor::Middle),
|
||
};
|
||
let text_origin = origin + h * x_offset + v_down * y_offset;
|
||
|
||
// Content rotation (radians) on top of table cell rotation.
|
||
let rot = content.map(|c| c.rotation as f32).unwrap_or(0.0) + cell.rotation as f32;
|
||
let layout = layout_mtext(&MTextRenderOpts {
|
||
// Not an MTEXT: text in a fixed box, never columnar.
|
||
columns: Default::default(),
|
||
value: text,
|
||
insertion: [text_origin.x as f64, text_origin.y as f64, origin.z as f64],
|
||
height: cell_h,
|
||
rect_w: 0.0,
|
||
rotation: rot,
|
||
style: &resolved,
|
||
attach_h_anchor,
|
||
v_anchor,
|
||
line_spacing_factor: 1.0,
|
||
exact_line_spacing: false,
|
||
rectangle_height: 0.0,
|
||
vertical_text: false,
|
||
want_glyph_boxes: false,
|
||
});
|
||
// Flatten TextStroke groups into the table's Lines buffer.
|
||
// Per-run inline `\C` / `\c` colour is dropped here because the
|
||
// table emits a single RenderObject::Lines for borders + text;
|
||
// tracking it would require splitting the table into multiple
|
||
// WireModels per cell colour. Borders + uniform-coloured runs
|
||
// honour the entity's outer colour.
|
||
for ts in &layout.strokes {
|
||
let ox = ts.origin[0] as f32;
|
||
let oy = ts.origin[1] as f32;
|
||
for stroke in &ts.strokes {
|
||
if stroke.len() < 2 {
|
||
continue;
|
||
}
|
||
if !pts.is_empty() {
|
||
pts.push([f32::NAN; 3]);
|
||
}
|
||
for &[x, y] in stroke {
|
||
pts.push([x + ox, y + oy, origin.z]);
|
||
}
|
||
}
|
||
for &[x, y] in &ts.fill_tris {
|
||
tris_pts.push([x + ox, y + oy, origin.z]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The layout above is in a local f32 frame (small magnitudes). Widen to
|
||
// f64 and add the world insertion so the absolute position carries full
|
||
// precision; tessellate.rs then applies world_offset.
|
||
let pts_f64: Vec<[f64; 3]> = pts
|
||
.into_iter()
|
||
.map(|[x, y, z]| {
|
||
if x.is_nan() {
|
||
[f64::NAN, f64::NAN, f64::NAN]
|
||
} else {
|
||
[x as f64 + base[0], y as f64 + base[1], z as f64 + base[2]]
|
||
}
|
||
})
|
||
.collect();
|
||
let fill_tris_f64: Vec<[f64; 3]> = tris_pts
|
||
.into_iter()
|
||
.map(|[x, y, z]| {
|
||
[x as f64 + base[0], y as f64 + base[1], z as f64 + base[2]]
|
||
})
|
||
.collect();
|
||
Some(RenderEntity {
|
||
pick_tris: Vec::new(),
|
||
object: RenderObject::Lines(pts_f64),
|
||
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Insertion)],
|
||
tangent_geoms: vec![],
|
||
key_vertices: vec![],
|
||
fill_tris: fill_tris_f64,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Builds colored geometry for tables without a stored display block.
|
||
pub fn tessellate_table(
|
||
tab: &Table,
|
||
document: &codec::CadDocument,
|
||
selected: bool,
|
||
entity_color: [f32; 4],
|
||
line_weight_px: f32,
|
||
// Annotation scale: multiplies the table's paper-size geometry so an
|
||
// annotative table renders at the current annotation scale. 1.0 for a
|
||
// non-annotative table (its geometry is already at model size).
|
||
anno_scale: f32,
|
||
) -> Vec<crate::scene::model::wire_model::WireModel> {
|
||
use crate::scene::convert::tess_util::aci_to_rgba;
|
||
use crate::scene::model::wire_model::WireModel;
|
||
use codec::types::Color;
|
||
use rustc_hash::FxHashMap as HashMap;
|
||
|
||
if tab.rows.is_empty() || tab.columns.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let rel = |p: Vec3| -> [f32; 3] {
|
||
[
|
||
(p.x as f64) as f32,
|
||
(p.y as f64) as f32,
|
||
(p.z as f64) 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: &codec::types::LineWeight| -> f32 {
|
||
match w {
|
||
codec::types::LineWeight::Value(v) if *v >= 0 => (*v as f32 / 100.0) * (96.0 / 25.4),
|
||
_ => line_weight_px,
|
||
}
|
||
};
|
||
|
||
let (h, v_down) = table_axes(tab);
|
||
// Flow direction: `Up` stacks rows upward instead of downward.
|
||
let table_style: Option<&codec::objects::TableStyle> =
|
||
tab.table_style_handle.and_then(|h| {
|
||
document.objects.get(&h).and_then(|obj| match obj {
|
||
codec::objects::ObjectType::TableStyle(ts) => Some(ts),
|
||
_ => None,
|
||
})
|
||
});
|
||
let flow_up = resolved_flow_up(tab, table_style);
|
||
let v_flow = if flow_up { -v_down } else { v_down };
|
||
|
||
let (col_offsets, row_offsets) = table_offsets(tab, anno_scale);
|
||
|
||
let title_suppressed = resolved_title_suppressed(tab, table_style);
|
||
let header_suppressed = resolved_header_suppressed(tab, table_style);
|
||
let top_label_rows = (usize::from(!title_suppressed) + usize::from(!header_suppressed))
|
||
.min(tab.rows.len());
|
||
let bottom_label_rows = usize::from(!tab.rows.is_empty());
|
||
let (horizontal_margin, vertical_margin) = resolved_table_margins(tab, table_style);
|
||
let h_margin = horizontal_margin as f32 * anno_scale;
|
||
let v_margin = vertical_margin as f32 * anno_scale;
|
||
|
||
let lookup_style = |hh: codec::Handle| -> Option<&codec::tables::TextStyle> {
|
||
document.text_styles.iter().find(|s| s.handle == hh)
|
||
};
|
||
let font_for_handle = |handle: Option<codec::Handle>| -> Option<String> {
|
||
handle.and_then(lookup_style).and_then(|s| {
|
||
let mut font_name = if !s.true_type_font.trim().is_empty() {
|
||
s.true_type_font.trim().to_string()
|
||
} else {
|
||
let file = s.font_file.trim();
|
||
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
|
||
let stem = basename.split('.').next().unwrap_or(basename).trim();
|
||
if !stem.is_empty() {
|
||
stem.to_string()
|
||
} else {
|
||
return None;
|
||
}
|
||
};
|
||
if !crate::scene::text::lff::is_builtin(&font_name) {
|
||
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
|
||
font_name = canonical;
|
||
}
|
||
}
|
||
Some(font_name)
|
||
})
|
||
};
|
||
let resolved_style_for_handle =
|
||
|handle: Option<codec::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),
|
||
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
|
||
}
|
||
};
|
||
|
||
// Accumulators keyed by quantised colour (+ weight for borders).
|
||
let mut fills: HashMap<[u8; 4], ([f32; 4], Vec<[f32; 3]>)> = HashMap::default();
|
||
let mut field_fills: Vec<[f32; 3]> = Vec::new();
|
||
// SDF cell text: glyph quads (per-vertex coloured) collected across all
|
||
// cells; emitted as one text-carrying wire at the end.
|
||
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> = Vec::new();
|
||
let mut borders: HashMap<([u8; 4], u32), ([f32; 4], f32, Vec<[f32; 3]>)> = HashMap::default();
|
||
let mut emitted: rustc_hash::FxHashSet<(i32, i32, i32, i32, i32, i32)> =
|
||
rustc_hash::FxHashSet::default();
|
||
let sel_col = WireModel::SELECTED;
|
||
|
||
let mut add_edge = |a: Vec3,
|
||
b: Vec3,
|
||
col: [f32; 4],
|
||
lw: f32,
|
||
borders: &mut HashMap<([u8; 4], u32), ([f32; 4], f32, Vec<[f32; 3]>)>| {
|
||
let k = (
|
||
(a.x * 1000.0) as i32,
|
||
(a.y * 1000.0) as i32,
|
||
(a.z * 1000.0) as i32,
|
||
(b.x * 1000.0) as i32,
|
||
(b.y * 1000.0) as i32,
|
||
(b.z * 1000.0) as i32,
|
||
);
|
||
let kr = (k.3, k.4, k.5, k.0, k.1, k.2);
|
||
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));
|
||
};
|
||
|
||
let normal = v3(&tab.normal).normalize_or(Vec3::Z);
|
||
for (ri, row) in tab.rows.iter().enumerate() {
|
||
let row_style: Option<&codec::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 Some((_, _, row_end, column_end)) =
|
||
merged_owner_and_span(tab, ri, ci)
|
||
else {
|
||
continue;
|
||
};
|
||
let frames = break_frames_for_row(
|
||
tab,
|
||
ri,
|
||
h,
|
||
v_flow,
|
||
&row_offsets,
|
||
anno_scale,
|
||
top_label_rows,
|
||
bottom_label_rows,
|
||
);
|
||
for (origin, row_top) in frames {
|
||
let merged_height = row_offsets
|
||
.get(row_end + 1)
|
||
.copied()
|
||
.unwrap_or(row_offsets[ri])
|
||
- row_offsets[ri];
|
||
let row_bot = row_top + merged_height;
|
||
let row_mid = (row_top + row_bot) * 0.5;
|
||
let col_left = col_offsets[ci];
|
||
let col_right = col_offsets
|
||
.get(column_end + 1)
|
||
.copied()
|
||
.unwrap_or(col_left);
|
||
let col_width = col_right - col_left;
|
||
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_style = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR,
|
||
);
|
||
let (fill_on, fill_color) = if let Some(cs) = fill_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 edge = |which: u8| -> (bool, [f32; 4], f32) {
|
||
let edge_flag = match which {
|
||
0 => codec::entities::table::CellEdgeFlags::TOP,
|
||
1 => codec::entities::table::CellEdgeFlags::RIGHT,
|
||
2 => codec::entities::table::CellEdgeFlags::BOTTOM,
|
||
_ => codec::entities::table::CellEdgeFlags::LEFT,
|
||
};
|
||
if let Some(cs) = style_for_border(tab, row, ci, cell, edge_flag) {
|
||
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, &mut borders);
|
||
}
|
||
let (rv, rc, rw) = edge(1);
|
||
if rv {
|
||
add_edge(tr, br_, rc, rw, &mut borders);
|
||
}
|
||
let (bv, bc, bw) = edge(2);
|
||
if bv {
|
||
add_edge(bl, br_, bc, bw, &mut borders);
|
||
}
|
||
let (lv, lc, lw) = edge(3);
|
||
if lv {
|
||
add_edge(tl, bl, lc, lw, &mut borders);
|
||
}
|
||
|
||
let value_contents: Vec<_> = cell
|
||
.contents
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, content)| {
|
||
let text = content_display_value(document, tab, content);
|
||
(!text.is_empty()).then_some((index, content, text))
|
||
})
|
||
.collect();
|
||
let fallback_text_height = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::TEXT_HEIGHT,
|
||
)
|
||
.map(|style| style.text_height as f32)
|
||
.or_else(|| row_style.map(|style| style.text_height as f32))
|
||
.filter(|height| *height > 1e-6)
|
||
.unwrap_or(0.18)
|
||
* anno_scale;
|
||
let fallback_margin_left = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_LEFT,
|
||
)
|
||
.map(|style| style.margin_left as f32 * anno_scale)
|
||
.unwrap_or(h_margin.max(fallback_text_height * 0.5));
|
||
let fallback_margin_right = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_RIGHT,
|
||
)
|
||
.map(|style| style.margin_right as f32 * anno_scale)
|
||
.unwrap_or(h_margin.max(fallback_text_height * 0.5));
|
||
let fallback_margin_top = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_TOP,
|
||
)
|
||
.map(|style| style.margin_top as f32 * anno_scale)
|
||
.unwrap_or(v_margin.max(fallback_text_height * 0.5));
|
||
let fallback_margin_bottom = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_BOTTOM,
|
||
)
|
||
.map(|style| style.margin_bottom as f32 * anno_scale)
|
||
.unwrap_or(v_margin.max(fallback_text_height * 0.5));
|
||
let fallback_layout = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::CONTENT_LAYOUT,
|
||
)
|
||
.map(|style| style.layout_flags)
|
||
.unwrap_or(codec::entities::table::ContentLayoutFlags::FLOW);
|
||
let fallback_alignment = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::ALIGNMENT,
|
||
)
|
||
.map(|style| style.alignment)
|
||
.or_else(|| row_style.map(|style| style.alignment as i32))
|
||
.unwrap_or(5);
|
||
let fallback_h_spacing = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_HORIZONTAL_SPACING,
|
||
)
|
||
.map(|style| style.horizontal_spacing as f32 * anno_scale)
|
||
.unwrap_or(0.0);
|
||
let fallback_v_spacing = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_VERTICAL_SPACING,
|
||
)
|
||
.map(|style| style.vertical_spacing as f32 * anno_scale)
|
||
.unwrap_or(0.0);
|
||
let fallback_sizes: Vec<_> = value_contents
|
||
.iter()
|
||
.map(|(_, content, text)| {
|
||
let height = cell_text_height(tab, row, ci, cell, content)
|
||
.map_or(fallback_text_height, |h| h as f32 * anno_scale);
|
||
let mut max_chars = 0usize;
|
||
let mut line_count = 0usize;
|
||
for line in text.split("\\P") {
|
||
max_chars = max_chars.max(line.chars().count());
|
||
line_count += 1;
|
||
}
|
||
(
|
||
(max_chars as f32 * height * 0.6).max(height * 0.5),
|
||
line_count.max(1) as f32 * height * 1.2,
|
||
)
|
||
})
|
||
.collect();
|
||
let fallback_centers = fallback_content_centers(
|
||
[
|
||
col_left + fallback_margin_left,
|
||
row_top + fallback_margin_top,
|
||
col_right - fallback_margin_right,
|
||
row_bot - fallback_margin_bottom,
|
||
],
|
||
&fallback_sizes,
|
||
fallback_layout,
|
||
fallback_alignment,
|
||
fallback_h_spacing,
|
||
fallback_v_spacing,
|
||
);
|
||
for (slot_index, (content_index, content, text)) in
|
||
value_contents.iter().enumerate()
|
||
{
|
||
let cell_h = cell_text_height(tab, row, ci, cell, content)
|
||
.or_else(|| {
|
||
row_style
|
||
.map(|style| style.text_height)
|
||
.filter(|height| *height > 1e-6)
|
||
})
|
||
.map(|height| height as f32)
|
||
.unwrap_or(0.18)
|
||
* anno_scale;
|
||
let margin_left = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_LEFT,
|
||
)
|
||
.map(|style| style.margin_left as f32 * anno_scale)
|
||
.filter(|margin| *margin > 1e-6)
|
||
.unwrap_or_else(|| h_margin.max(cell_h * 0.5));
|
||
let margin_right = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_RIGHT,
|
||
)
|
||
.map(|style| style.margin_right as f32 * anno_scale)
|
||
.filter(|margin| *margin > 1e-6)
|
||
.unwrap_or_else(|| h_margin.max(cell_h * 0.5));
|
||
let margin_top = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_TOP,
|
||
)
|
||
.map(|style| style.margin_top as f32 * anno_scale)
|
||
.filter(|margin| *margin > 1e-6)
|
||
.unwrap_or_else(|| v_margin.max(cell_h * 0.5));
|
||
let margin_bottom = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::MARGIN_BOTTOM,
|
||
)
|
||
.map(|style| style.margin_bottom as f32 * anno_scale)
|
||
.filter(|margin| *margin > 1e-6)
|
||
.unwrap_or_else(|| v_margin.max(cell_h * 0.5));
|
||
let style_handle = cell_text_style(document, tab, row, ci, cell, Some(content))
|
||
.or_else(|| row_style.and_then(|style| style.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 = (content.alignment != 0)
|
||
.then_some(content.alignment)
|
||
.or_else(|| {
|
||
style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::ALIGNMENT,
|
||
)
|
||
.map(|style| style.alignment)
|
||
.filter(|alignment| *alignment != 0)
|
||
})
|
||
.or_else(|| row_style.map(|style| style.alignment as i32))
|
||
.unwrap_or(5);
|
||
let horiz = ((align - 1).rem_euclid(3)) + 1;
|
||
let vert = ((align - 1) / 3) + 1;
|
||
let (mut x_offset, mut attach_h_anchor) = match horiz {
|
||
1 => (col_left + margin_left, 0.0_f32),
|
||
3 => (col_right - margin_right, 1.0_f32),
|
||
_ => (col_left + col_width * 0.5, 0.5_f32),
|
||
};
|
||
let (mut y_offset, mut v_anchor) = match vert {
|
||
1 => (row_top + margin_top, MTextVAnchor::Top),
|
||
3 => (row_bot - margin_bottom, MTextVAnchor::Bottom),
|
||
_ => (row_mid, MTextVAnchor::Middle),
|
||
};
|
||
let mut z_offset = 0.0;
|
||
if let Some(geometry) = resolved_content_geometry(
|
||
document,
|
||
tab,
|
||
ri,
|
||
ci,
|
||
cell,
|
||
*content_index,
|
||
) {
|
||
x_offset =
|
||
col_left + geometry.distance_to_center.x as f32 * anno_scale;
|
||
y_offset =
|
||
row_top - geometry.distance_to_center.y as f32 * anno_scale;
|
||
z_offset = geometry.distance_to_center.z as f32 * anno_scale;
|
||
attach_h_anchor = 0.5;
|
||
v_anchor = MTextVAnchor::Middle;
|
||
} else if value_contents.len() > 1 {
|
||
if let Some((x, y)) = fallback_centers.get(slot_index) {
|
||
x_offset = *x;
|
||
y_offset = *y;
|
||
attach_h_anchor = 0.5;
|
||
v_anchor = MTextVAnchor::Middle;
|
||
}
|
||
}
|
||
let to = origin
|
||
+ h * x_offset
|
||
+ v_flow * y_offset
|
||
+ normal * z_offset;
|
||
let content_rotation_explicit = content.format_property_flags
|
||
& codec::entities::table::CellStylePropertyFlags::ROTATION.bits() as i32
|
||
!= 0;
|
||
let rot = if content_rotation_explicit {
|
||
content.rotation as f32
|
||
} else {
|
||
style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::ROTATION,
|
||
)
|
||
.map(|style| style.rotation as f32)
|
||
.unwrap_or(cell.rotation as f32)
|
||
};
|
||
let layout = layout_mtext(&MTextRenderOpts {
|
||
columns: Default::default(),
|
||
value: text,
|
||
insertion: [to.x as f64, to.y as f64, to.z as f64],
|
||
height: cell_h,
|
||
rect_w: (col_width - margin_left - margin_right).max(0.0),
|
||
rotation: rot,
|
||
style: &resolved,
|
||
attach_h_anchor,
|
||
v_anchor,
|
||
line_spacing_factor: 1.0,
|
||
exact_line_spacing: false,
|
||
rectangle_height: 0.0,
|
||
vertical_text: false,
|
||
want_glyph_boxes: false,
|
||
});
|
||
// FIELDDISPLAY: a field cell's text on a gray box, one per line,
|
||
// from the same layout's character cells (screen only).
|
||
if content.field_handle.is_some() && crate::entities::field::display() {
|
||
let cells = layout_mtext(&MTextRenderOpts {
|
||
columns: Default::default(),
|
||
value: text,
|
||
insertion: [to.x as f64, to.y as f64, to.z as f64],
|
||
height: cell_h,
|
||
rect_w: (col_width - margin_left - margin_right).max(0.0),
|
||
rotation: rot,
|
||
style: &resolved,
|
||
attach_h_anchor,
|
||
v_anchor,
|
||
line_spacing_factor: 1.0,
|
||
exact_line_spacing: false,
|
||
rectangle_height: 0.0,
|
||
vertical_text: false,
|
||
want_glyph_boxes: true,
|
||
})
|
||
.glyph_boxes;
|
||
let (sin_r, cos_r) = (rot as f64).sin_cos();
|
||
let mut lines: Vec<[f64; 4]> = Vec::new();
|
||
for b in &cells {
|
||
let [l, base, r, top] = b.local.map(|v| v as f64);
|
||
match lines.last_mut() {
|
||
Some(c) if (c[1] - base).abs() <= 1e-3 * (top - base).max(c[3]) => {
|
||
c[0] = c[0].min(l);
|
||
c[2] = c[2].max(r);
|
||
c[3] = c[3].max(top - base);
|
||
}
|
||
_ => lines.push([l, base, r, top - base]),
|
||
}
|
||
}
|
||
for [l, base, r, h] in lines.into_iter().filter(|[l, _, r, _]| r > l) {
|
||
let (b, t) = (base - crate::entities::field::BOX_BELOW * h, base + crate::entities::field::BOX_ABOVE * h);
|
||
let w = |x: f64, y: f64| [
|
||
(to.x as f64 + x * cos_r - y * sin_r) as f32,
|
||
(to.y as f64 + x * sin_r + y * cos_r) as f32,
|
||
to.z as f32,
|
||
];
|
||
let q = [w(l, b), w(r, b), w(r, t), w(l, t)];
|
||
field_fills.extend([q[0], q[1], q[2], q[0], q[2], q[3]]);
|
||
}
|
||
}
|
||
let tcol = if selected {
|
||
sel_col
|
||
} else if !matches!(
|
||
content.color,
|
||
Color::ByLayer | Color::ByBlock
|
||
) {
|
||
resolve_col(&content.color, entity_color)
|
||
} else if let Some(style) = style_for_property(
|
||
tab,
|
||
row,
|
||
ci,
|
||
cell,
|
||
codec::entities::table::CellStylePropertyFlags::CONTENT_COLOR,
|
||
) {
|
||
resolve_col(&style.content_color, entity_color)
|
||
} else if let Some(style) = row_style {
|
||
resolve_col(&style.text_color, entity_color)
|
||
} else {
|
||
entity_color
|
||
};
|
||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||
for stroke in &layout.strokes {
|
||
let Some(run) = &stroke.run else {
|
||
continue;
|
||
};
|
||
if crate::scene::text::web_font::requires_shaping(&run.text) {
|
||
if !stroke.fill_tris.is_empty() {
|
||
let entry = fills
|
||
.entry(key4(tcol))
|
||
.or_insert_with(|| (tcol, Vec::new()));
|
||
for &[x, y] in &stroke.fill_tris {
|
||
entry.1.push([
|
||
stroke.origin[0] as f32 + x,
|
||
stroke.origin[1] as f32 + y,
|
||
to.z as f32,
|
||
]);
|
||
}
|
||
}
|
||
if !stroke.strokes.is_empty() {
|
||
let entry = borders
|
||
.entry((key4(tcol), (line_weight_px * 100.0) as u32))
|
||
.or_insert_with(|| (tcol, line_weight_px, Vec::new()));
|
||
for s in &stroke.strokes {
|
||
if s.len() < 2 {
|
||
continue;
|
||
}
|
||
if !entry.2.is_empty() {
|
||
entry.2.push([f32::NAN; 3]);
|
||
}
|
||
for &[x, y] in s {
|
||
entry.2.push([
|
||
stroke.origin[0] as f32 + x,
|
||
stroke.origin[1] as f32 + y,
|
||
to.z as f32,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
let (quads, pen_adv) =
|
||
crate::scene::text::glyph_quads::layout_glyph_quads(
|
||
&mut atlas,
|
||
run.height,
|
||
run.rotation,
|
||
run.width_factor,
|
||
run.oblique,
|
||
run.tracking,
|
||
&run.font,
|
||
run.bold,
|
||
&run.text,
|
||
);
|
||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||
&mut text_verts,
|
||
&quads,
|
||
[stroke.origin[0], stroke.origin[1], to.z as f64],
|
||
1.0,
|
||
tcol,
|
||
0.0,
|
||
);
|
||
{
|
||
use crate::scene::model::wire_model::{
|
||
clean_searchable_text, SearchableTextRun,
|
||
};
|
||
let visible = clean_searchable_text(&run.text);
|
||
if !visible.is_empty() {
|
||
searchable.push(SearchableTextRun {
|
||
text: visible,
|
||
origin: [stroke.origin[0], stroke.origin[1], to.z as f64],
|
||
height: run.height,
|
||
rotation: run.rotation,
|
||
color: tcol,
|
||
bold: run.bold,
|
||
font: run.font.clone(),
|
||
adv_width: pen_adv,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points,
|
||
points_low: Vec::new(),
|
||
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,
|
||
fill_tris,
|
||
// fill_tris_low intentionally empty: this fill renders on the
|
||
// top-level path, where consumers (face3d_gpu, xclip) treat a
|
||
// short low half as all-zero, so it draws at f32 precision
|
||
// (sub-metre error at UTM scale) — not a crash. Follow-up:
|
||
// double-single-split via points_to_ds to match emit_wire.
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
}
|
||
};
|
||
|
||
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));
|
||
}
|
||
}
|
||
if !field_fills.is_empty() {
|
||
let mut background = mk(crate::entities::field::BACKGROUND, vec![], field_fills, 1.0);
|
||
background.plot_visible = false;
|
||
out.push(background);
|
||
}
|
||
for (_, (color, lw, pts)) in borders {
|
||
if !pts.is_empty() {
|
||
out.push(mk(color, pts, vec![], lw));
|
||
}
|
||
}
|
||
// SDF cell text: one wire carrying the glyph quads (per-vertex coloured) +
|
||
// a glyph-bounds AABB (f64 accumulate → f32) so the text draws + picks;
|
||
// empty points so it adds no stroke geometry.
|
||
if !text_verts.is_empty() {
|
||
let (mut nx, mut ny, mut xx, mut xy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||
for v in &text_verts {
|
||
let x = v.pos[0] as f64 + v.pos_low[0] as f64;
|
||
let y = v.pos[1] as f64 + v.pos_low[1] as f64;
|
||
nx = nx.min(x);
|
||
xx = xx.max(x);
|
||
ny = ny.min(y);
|
||
xy = xy.max(y);
|
||
}
|
||
let mut w = mk(entity_color, vec![], vec![], line_weight_px);
|
||
w.aabb = [nx as f32, ny as f32, xx as f32, xy as f32];
|
||
w.text_verts = text_verts;
|
||
w.searchable_text = searchable;
|
||
out.push(w);
|
||
}
|
||
out
|
||
}
|
||
|
||
impl Grippable for Table {
|
||
fn grips(&self) -> Vec<GripDef> {
|
||
let origin = glam::DVec3::new(
|
||
self.insertion_point.x,
|
||
self.insertion_point.y,
|
||
self.insertion_point.z,
|
||
);
|
||
let horizontal = glam::DVec3::new(
|
||
self.horizontal_direction.x,
|
||
self.horizontal_direction.y,
|
||
self.horizontal_direction.z,
|
||
)
|
||
.normalize_or(glam::DVec3::X);
|
||
let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z)
|
||
.normalize_or(glam::DVec3::Z);
|
||
let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y);
|
||
let width = self.total_width();
|
||
let height = self.total_height();
|
||
let mut grips = vec![square_grip(
|
||
0,
|
||
origin,
|
||
)];
|
||
grips.push(square_grip(1, origin + horizontal * width));
|
||
grips.push(square_grip(2, origin + down * height));
|
||
let mut offset = 0.0;
|
||
for (column, definition) in self.columns.iter().enumerate() {
|
||
offset += definition.width;
|
||
if column + 1 < self.columns.len() {
|
||
grips.push(square_grip(
|
||
100 + column,
|
||
origin + horizontal * offset + down * (height * 0.5),
|
||
));
|
||
}
|
||
}
|
||
offset = 0.0;
|
||
for (row, definition) in self.rows.iter().enumerate() {
|
||
offset += definition.height;
|
||
if row + 1 < self.rows.len() {
|
||
grips.push(square_grip(
|
||
1000 + row,
|
||
origin + horizontal * (width * 0.5) + down * offset,
|
||
));
|
||
}
|
||
}
|
||
for (index, data) in self.break_data.iter().enumerate() {
|
||
grips.push(square_grip(
|
||
2000 + index,
|
||
origin + glam::DVec3::new(data.position.x, data.position.y, data.position.z),
|
||
));
|
||
}
|
||
grips
|
||
}
|
||
|
||
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
|
||
if let GripApply::Translate(delta) = apply {
|
||
if let Some(grip) = self.grips().into_iter().find(|grip| grip.id == grip_id) {
|
||
self.apply_grip(grip_id, GripApply::Absolute(grip.world + delta));
|
||
}
|
||
return;
|
||
}
|
||
let GripApply::Absolute(point) = apply else {
|
||
return;
|
||
};
|
||
if grip_id == 0 {
|
||
self.insertion_point.x = point.x;
|
||
self.insertion_point.y = point.y;
|
||
self.insertion_point.z = point.z;
|
||
return;
|
||
}
|
||
let origin = glam::DVec3::new(
|
||
self.insertion_point.x,
|
||
self.insertion_point.y,
|
||
self.insertion_point.z,
|
||
);
|
||
let horizontal = glam::DVec3::new(
|
||
self.horizontal_direction.x,
|
||
self.horizontal_direction.y,
|
||
self.horizontal_direction.z,
|
||
)
|
||
.normalize_or(glam::DVec3::X);
|
||
let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z)
|
||
.normalize_or(glam::DVec3::Z);
|
||
let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y);
|
||
if grip_id == 1 {
|
||
let width = (point - origin).dot(horizontal).max(1.0e-6);
|
||
let current = self.total_width();
|
||
if current > 1.0e-12 {
|
||
for column in &mut self.columns {
|
||
column.width *= width / current;
|
||
}
|
||
}
|
||
} else if grip_id == 2 {
|
||
let height = (point - origin).dot(down).max(1.0e-6);
|
||
let current = self.total_height();
|
||
if current > 1.0e-12 {
|
||
for row in &mut self.rows {
|
||
row.height *= height / current;
|
||
}
|
||
}
|
||
} else if (100..1000).contains(&grip_id) {
|
||
let column = grip_id - 100;
|
||
let before: f64 = self.columns.iter().take(column).map(|item| item.width).sum();
|
||
if let Some(definition) = self.columns.get_mut(column) {
|
||
definition.width = ((point - origin).dot(horizontal) - before).max(1.0e-6);
|
||
}
|
||
} else if (1000..2000).contains(&grip_id) {
|
||
let row = grip_id - 1000;
|
||
let before: f64 = self.rows.iter().take(row).map(|item| item.height).sum();
|
||
if let Some(definition) = self.rows.get_mut(row) {
|
||
definition.height = ((point - origin).dot(down) - before).max(1.0e-6);
|
||
}
|
||
} else if grip_id >= 2000 {
|
||
if let Some(data) = self.break_data.get_mut(grip_id - 2000) {
|
||
let offset = point - origin;
|
||
data.position = codec::types::Vector3::new(offset.x, offset.y, offset.z);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl PropertyEditable for Table {
|
||
fn geometry_properties(&self, text_style_names: &[String]) -> Vec<PropSection> {
|
||
use crate::entities::common::edit_prop as edit;
|
||
use codec::entities::table::{BreakOptionFlags, CellStateFlags, CellStylePropertyFlags};
|
||
let bool_text = |value: bool| if value { t!("Yes") } else { t!("No") }.into_owned();
|
||
let toggle = |label: &str, field: &'static str, value: bool, enabled: bool| -> Property {
|
||
Property {
|
||
label: label.into(),
|
||
field,
|
||
value: if enabled {
|
||
PropValue::BoolToggle { field, value }
|
||
} else {
|
||
PropValue::ReadOnly(bool_text(value))
|
||
},
|
||
}
|
||
};
|
||
let choice = |label: &str,
|
||
field: &'static str,
|
||
selected: String,
|
||
options: Vec<String>,
|
||
enabled: bool|
|
||
-> Property {
|
||
Property {
|
||
label: label.into(),
|
||
field,
|
||
value: if enabled {
|
||
PropValue::Choice { selected, options }
|
||
} else {
|
||
PropValue::ReadOnly(selected)
|
||
},
|
||
}
|
||
};
|
||
let number = |label: &str, field: &'static str, value: f64, enabled: bool| -> Property {
|
||
if enabled {
|
||
edit(label, field, value)
|
||
} else {
|
||
ro(label, field, crate::entities::common::format_length(value))
|
||
}
|
||
};
|
||
let text = |label: &str, field: &'static str, value: String, enabled: bool| -> Property {
|
||
Property {
|
||
label: label.into(),
|
||
field,
|
||
value: if enabled {
|
||
PropValue::PlainText(value)
|
||
} else {
|
||
PropValue::ReadOnly(value)
|
||
},
|
||
}
|
||
};
|
||
let color_text = |color: codec::types::Color| match color {
|
||
codec::types::Color::None => "None".to_string(),
|
||
codec::types::Color::ByLayer => "ByLayer".to_string(),
|
||
codec::types::Color::ByBlock => "ByBlock".to_string(),
|
||
codec::types::Color::Index(index) => index.to_string(),
|
||
codec::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"),
|
||
};
|
||
let color = |label: &str,
|
||
field: &'static str,
|
||
value: codec::types::Color,
|
||
enabled: bool|
|
||
-> Property {
|
||
Property {
|
||
label: label.into(),
|
||
field,
|
||
value: if enabled {
|
||
PropValue::ColorChoice(value)
|
||
} else {
|
||
PropValue::ReadOnly(color_text(value))
|
||
},
|
||
}
|
||
};
|
||
// Direction = angle of the horizontal direction vector in the XY plane.
|
||
let direction_deg =
|
||
(self.horizontal_direction.y.atan2(self.horizontal_direction.x)).to_degrees();
|
||
let break_height = self.break_data.first().map(|data| data.height).unwrap_or(0.0);
|
||
let breaks_enabled = self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS);
|
||
let manual_positions = self
|
||
.break_options
|
||
.contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS);
|
||
let manual_heights = self
|
||
.break_options
|
||
.contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS);
|
||
let cell_count = self.rows.len().saturating_mul(self.columns.len());
|
||
let cell_index = prop_current_cell().min(cell_count.saturating_sub(1));
|
||
let cell_row = if self.columns.is_empty() {
|
||
0
|
||
} else {
|
||
cell_index / self.columns.len()
|
||
};
|
||
let cell_column = if self.columns.is_empty() {
|
||
0
|
||
} else {
|
||
cell_index % self.columns.len()
|
||
};
|
||
let table_row = self.rows.get(cell_row);
|
||
let cell = self.cell(cell_row, cell_column);
|
||
let row_style = table_row.and_then(|row| row.style.as_ref());
|
||
let column_style = self
|
||
.columns
|
||
.get(cell_column)
|
||
.and_then(|column| column.style.as_ref());
|
||
let style_for = |property| {
|
||
table_row.and_then(|row| {
|
||
cell.and_then(|cell| {
|
||
style_for_property(self, row, cell_column, cell, property)
|
||
})
|
||
})
|
||
};
|
||
let alignment_style = style_for(CellStylePropertyFlags::ALIGNMENT);
|
||
let alignment = match alignment_style.map(|style| style.alignment).unwrap_or(5) {
|
||
1 => "Top Left",
|
||
2 => "Top Center",
|
||
3 => "Top Right",
|
||
4 => "Middle Left",
|
||
6 => "Middle Right",
|
||
7 => "Bottom Left",
|
||
8 => "Bottom Center",
|
||
9 => "Bottom Right",
|
||
_ => "Middle Center",
|
||
};
|
||
let state = cell.map(|cell| cell.state).unwrap_or_default();
|
||
let content_editable = !state.intersects(
|
||
CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY,
|
||
);
|
||
let format_editable = !state.intersects(
|
||
CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY,
|
||
);
|
||
let immutable_lock = state.intersects(
|
||
CellStateFlags::CONTENT_READ_ONLY | CellStateFlags::FORMAT_READ_ONLY,
|
||
);
|
||
let cell_locked = state.intersects(
|
||
CellStateFlags::CONTENT_LOCKED
|
||
| CellStateFlags::CONTENT_READ_ONLY
|
||
| CellStateFlags::FORMAT_LOCKED
|
||
| CellStateFlags::FORMAT_READ_ONLY,
|
||
);
|
||
let table_flow_up = self
|
||
.legacy_style_override
|
||
.as_ref()
|
||
.and_then(|style| style.flow_direction)
|
||
.map(|flow| flow != 0)
|
||
.unwrap_or_else(|| {
|
||
self.base_style.as_ref().is_some_and(|style| {
|
||
style
|
||
.property_flags
|
||
.contains(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP)
|
||
})
|
||
});
|
||
let legacy = self.legacy_style_override.as_ref();
|
||
let title_suppressed = legacy
|
||
.and_then(|style| style.title_suppressed)
|
||
.unwrap_or(false);
|
||
let header_suppressed = legacy
|
||
.and_then(|style| style.header_suppressed)
|
||
.unwrap_or(false);
|
||
let horizontal_margin = legacy
|
||
.and_then(|style| style.horizontal_cell_margin)
|
||
.or_else(|| self.base_style.as_ref().map(|style| style.margin_left))
|
||
.unwrap_or(0.06);
|
||
let vertical_margin = legacy
|
||
.and_then(|style| style.vertical_cell_margin)
|
||
.or_else(|| self.base_style.as_ref().map(|style| style.margin_top))
|
||
.unwrap_or(0.06);
|
||
let uniform_column_width = self.columns.first().map(|column| column.width).unwrap_or(0.0);
|
||
let columns_uniform = self
|
||
.columns
|
||
.iter()
|
||
.all(|column| (column.width - uniform_column_width).abs() <= 1.0e-9);
|
||
let uniform_row_height = self.rows.first().map(|row| row.height).unwrap_or(0.0);
|
||
let rows_uniform = self
|
||
.rows
|
||
.iter()
|
||
.all(|row| (row.height - uniform_row_height).abs() <= 1.0e-9);
|
||
let uniform_number = |label: &str,
|
||
field: &'static str,
|
||
value: f64,
|
||
uniform: bool|
|
||
-> Property {
|
||
if uniform {
|
||
edit(label, field, value)
|
||
} else {
|
||
Property {
|
||
label: label.into(),
|
||
field,
|
||
value: PropValue::PlainText(t!("Varies").into_owned()),
|
||
}
|
||
}
|
||
};
|
||
let override_count = self
|
||
.rows
|
||
.iter()
|
||
.filter(|row| row.style.is_some())
|
||
.count()
|
||
+ self
|
||
.columns
|
||
.iter()
|
||
.filter(|column| column.style.is_some())
|
||
.count()
|
||
+ self
|
||
.rows
|
||
.iter()
|
||
.flat_map(|row| row.cells.iter())
|
||
.filter(|cell| cell.style.is_some())
|
||
.count()
|
||
+ usize::from(self.base_style.is_some() || self.legacy_style_override.is_some());
|
||
|
||
let mut sections = vec![
|
||
PropSection {
|
||
title: t!("Table").into_owned(),
|
||
props: vec![
|
||
ro(t!("Table style").as_ref(), "tbl_style_handle", "Standard"),
|
||
toggle(
|
||
t!("Title suppressed").as_ref(),
|
||
"tbl_title_suppressed",
|
||
title_suppressed,
|
||
true,
|
||
),
|
||
toggle(
|
||
t!("Header suppressed").as_ref(),
|
||
"tbl_header_suppressed",
|
||
header_suppressed,
|
||
true,
|
||
),
|
||
choice(
|
||
t!("Flow direction").as_ref(),
|
||
"tbl_flow_direction",
|
||
if table_flow_up { "Up" } else { "Down" }.to_string(),
|
||
vec!["Down".into(), "Up".into()],
|
||
true,
|
||
),
|
||
edit(t!("Direction").as_ref(), "tbl_direction", direction_deg),
|
||
edit(t!("Rows").as_ref(), "tbl_rows", self.rows.len() as f64),
|
||
edit(t!("Columns").as_ref(), "tbl_cols", self.columns.len() as f64),
|
||
uniform_number(
|
||
t!("Column width").as_ref(),
|
||
"tbl_column_width",
|
||
uniform_column_width,
|
||
columns_uniform,
|
||
),
|
||
uniform_number(
|
||
t!("Row height").as_ref(),
|
||
"tbl_row_height",
|
||
uniform_row_height,
|
||
rows_uniform,
|
||
),
|
||
edit(t!("Table width").as_ref(), "tbl_width", self.total_width()),
|
||
edit(t!("Table height").as_ref(), "tbl_height", self.total_height()),
|
||
edit(
|
||
t!("Horizontal cell margin").as_ref(),
|
||
"tbl_horizontal_margin",
|
||
horizontal_margin,
|
||
),
|
||
edit(
|
||
t!("Vertical cell margin").as_ref(),
|
||
"tbl_vertical_margin",
|
||
vertical_margin,
|
||
),
|
||
ro(
|
||
t!("Table overrides").as_ref(),
|
||
"tbl_overrides",
|
||
if override_count == 0 {
|
||
t!("None").into_owned()
|
||
} else {
|
||
override_count.to_string()
|
||
},
|
||
),
|
||
],
|
||
},
|
||
PropSection {
|
||
title: t!("Geometry").into_owned(),
|
||
props: vec![
|
||
edit(t!("Insertion X").as_ref(), "tbl_insert_x", self.insertion_point.x),
|
||
edit(t!("Insertion Y").as_ref(), "tbl_insert_y", self.insertion_point.y),
|
||
edit(t!("Insertion Z").as_ref(), "tbl_insert_z", self.insertion_point.z),
|
||
ro(t!("Normal X").as_ref(), "tbl_normal_x", format!("{:.4}", self.normal.x)),
|
||
ro(t!("Normal Y").as_ref(), "tbl_normal_y", format!("{:.4}", self.normal.y)),
|
||
ro(t!("Normal Z").as_ref(), "tbl_normal_z", format!("{:.4}", self.normal.z)),
|
||
],
|
||
},
|
||
];
|
||
|
||
if prop_current_cell_active() {
|
||
let cell_type = match cell.map(|cell| cell.cell_type) {
|
||
Some(codec::entities::table::CellType::Block) => "Block",
|
||
_ => "Text",
|
||
};
|
||
let data_type = cell
|
||
.and_then(|cell| cell.contents.first())
|
||
.map(|content| match content.value.value_type {
|
||
codec::entities::table::CellValueType::Long => "Integer",
|
||
codec::entities::table::CellValueType::Double => "Decimal",
|
||
codec::entities::table::CellValueType::Date => "Date",
|
||
codec::entities::table::CellValueType::Point2D => "Point 2D",
|
||
codec::entities::table::CellValueType::Point3D => "Point 3D",
|
||
codec::entities::table::CellValueType::Handle => "Handle",
|
||
_ => "Text",
|
||
})
|
||
.unwrap_or("Text");
|
||
let style_source = if cell.and_then(|cell| cell.style.as_ref()).is_some() {
|
||
"Cell override"
|
||
} else if row_style.is_some() {
|
||
"Row override"
|
||
} else if column_style.is_some() {
|
||
"Column override"
|
||
} else {
|
||
"Table style"
|
||
};
|
||
let text_style = style_for(CellStylePropertyFlags::TEXT_STYLE)
|
||
.map(|style| style.text_style_name.clone())
|
||
.filter(|name| !name.is_empty())
|
||
.unwrap_or_else(|| "Standard".to_string());
|
||
let mut available_text_styles = text_style_names.to_vec();
|
||
if !available_text_styles
|
||
.iter()
|
||
.any(|name| name.eq_ignore_ascii_case(&text_style))
|
||
{
|
||
available_text_styles.insert(0, text_style.clone());
|
||
}
|
||
let rotation = style_for(CellStylePropertyFlags::ROTATION)
|
||
.map(|style| style.rotation)
|
||
.or_else(|| cell.map(|cell| cell.rotation))
|
||
.unwrap_or(0.0)
|
||
.to_degrees();
|
||
sections.push(PropSection {
|
||
title: t!("Cell").into_owned(),
|
||
props: vec![
|
||
Property {
|
||
label: t!("Current cell").into_owned(),
|
||
field: "tbl_current_cell",
|
||
value: PropValue::Stepper {
|
||
field: "tbl_current_cell",
|
||
display: format!(
|
||
"{},{} ({} / {})",
|
||
cell_row,
|
||
cell_column,
|
||
cell_index.saturating_add(1),
|
||
cell_count
|
||
),
|
||
},
|
||
},
|
||
ro(t!("Row").as_ref(), "tbl_cell_row", cell_row.to_string()),
|
||
ro(
|
||
t!("Column").as_ref(),
|
||
"tbl_cell_column",
|
||
cell_column.to_string(),
|
||
),
|
||
ro(t!("Cell style").as_ref(), "tbl_cell_style", style_source),
|
||
choice(
|
||
t!("Cell type").as_ref(),
|
||
"tbl_cell_type",
|
||
cell_type.to_string(),
|
||
vec!["Text".into(), "Block".into()],
|
||
content_editable,
|
||
),
|
||
text(
|
||
t!("Contents").as_ref(),
|
||
"tbl_cell_text",
|
||
cell.map(|cell| cell.text_value().to_string()).unwrap_or_default(),
|
||
content_editable,
|
||
),
|
||
choice(
|
||
t!("Alignment").as_ref(),
|
||
"tbl_cell_alignment",
|
||
alignment.to_string(),
|
||
vec![
|
||
"Top Left".into(),
|
||
"Top Center".into(),
|
||
"Top Right".into(),
|
||
"Middle Left".into(),
|
||
"Middle Center".into(),
|
||
"Middle Right".into(),
|
||
"Bottom Left".into(),
|
||
"Bottom Center".into(),
|
||
"Bottom Right".into(),
|
||
],
|
||
format_editable,
|
||
),
|
||
choice(
|
||
t!("Text style").as_ref(),
|
||
"tbl_cell_text_style",
|
||
text_style,
|
||
available_text_styles,
|
||
format_editable,
|
||
),
|
||
choice(
|
||
t!("Text rotation").as_ref(),
|
||
"tbl_cell_rotation",
|
||
format!("{rotation:.0}"),
|
||
vec!["0".into(), "90".into(), "180".into(), "270".into()],
|
||
format_editable,
|
||
),
|
||
number(
|
||
t!("Text height").as_ref(),
|
||
"tbl_cell_text_height",
|
||
style_for(CellStylePropertyFlags::TEXT_HEIGHT)
|
||
.map(|style| style.text_height)
|
||
.unwrap_or(0.18),
|
||
format_editable,
|
||
),
|
||
color(
|
||
t!("Content color").as_ref(),
|
||
"tbl_cell_content_color",
|
||
style_for(CellStylePropertyFlags::CONTENT_COLOR)
|
||
.map(|style| style.content_color)
|
||
.unwrap_or(codec::types::Color::ByBlock),
|
||
format_editable,
|
||
),
|
||
color(
|
||
t!("Background color").as_ref(),
|
||
"tbl_cell_background_color",
|
||
style_for(CellStylePropertyFlags::BACKGROUND_COLOR)
|
||
.map(|style| style.background_color)
|
||
.unwrap_or(codec::types::Color::ByBlock),
|
||
format_editable,
|
||
),
|
||
choice(
|
||
t!("Data type").as_ref(),
|
||
"tbl_cell_data_type",
|
||
data_type.to_string(),
|
||
vec![
|
||
"Text".into(),
|
||
"Integer".into(),
|
||
"Decimal".into(),
|
||
"Date".into(),
|
||
"Point 2D".into(),
|
||
"Point 3D".into(),
|
||
"Handle".into(),
|
||
],
|
||
format_editable,
|
||
),
|
||
text(
|
||
t!("Data format").as_ref(),
|
||
"tbl_cell_format",
|
||
style_for(CellStylePropertyFlags::DATA_FORMAT)
|
||
.map(|style| style.value_format.clone())
|
||
.unwrap_or_default(),
|
||
format_editable,
|
||
),
|
||
toggle(
|
||
t!("Background fill").as_ref(),
|
||
"tbl_cell_fill",
|
||
style_for(CellStylePropertyFlags::BACKGROUND_COLOR)
|
||
.is_some_and(|style| style.fill_enabled),
|
||
format_editable,
|
||
),
|
||
number(
|
||
t!("Left margin").as_ref(),
|
||
"tbl_cell_margin_left",
|
||
style_for(CellStylePropertyFlags::MARGIN_LEFT)
|
||
.map(|style| style.margin_left)
|
||
.unwrap_or(horizontal_margin),
|
||
format_editable,
|
||
),
|
||
number(
|
||
t!("Top margin").as_ref(),
|
||
"tbl_cell_margin_top",
|
||
style_for(CellStylePropertyFlags::MARGIN_TOP)
|
||
.map(|style| style.margin_top)
|
||
.unwrap_or(vertical_margin),
|
||
format_editable,
|
||
),
|
||
number(
|
||
t!("Right margin").as_ref(),
|
||
"tbl_cell_margin_right",
|
||
style_for(CellStylePropertyFlags::MARGIN_RIGHT)
|
||
.map(|style| style.margin_right)
|
||
.unwrap_or(horizontal_margin),
|
||
format_editable,
|
||
),
|
||
number(
|
||
t!("Bottom margin").as_ref(),
|
||
"tbl_cell_margin_bottom",
|
||
style_for(CellStylePropertyFlags::MARGIN_BOTTOM)
|
||
.map(|style| style.margin_bottom)
|
||
.unwrap_or(vertical_margin),
|
||
format_editable,
|
||
),
|
||
toggle(
|
||
t!("Top border").as_ref(),
|
||
"tbl_cell_border_top",
|
||
table_row
|
||
.and_then(|row| {
|
||
cell.and_then(|cell| {
|
||
style_for_border(
|
||
self,
|
||
row,
|
||
cell_column,
|
||
cell,
|
||
codec::entities::table::CellEdgeFlags::TOP,
|
||
)
|
||
})
|
||
})
|
||
.is_none_or(|style| !style.top_border.invisible),
|
||
format_editable,
|
||
),
|
||
toggle(
|
||
t!("Right border").as_ref(),
|
||
"tbl_cell_border_right",
|
||
table_row
|
||
.and_then(|row| {
|
||
cell.and_then(|cell| {
|
||
style_for_border(
|
||
self,
|
||
row,
|
||
cell_column,
|
||
cell,
|
||
codec::entities::table::CellEdgeFlags::RIGHT,
|
||
)
|
||
})
|
||
})
|
||
.is_none_or(|style| !style.right_border.invisible),
|
||
format_editable,
|
||
),
|
||
toggle(
|
||
t!("Bottom border").as_ref(),
|
||
"tbl_cell_border_bottom",
|
||
table_row
|
||
.and_then(|row| {
|
||
cell.and_then(|cell| {
|
||
style_for_border(
|
||
self,
|
||
row,
|
||
cell_column,
|
||
cell,
|
||
codec::entities::table::CellEdgeFlags::BOTTOM,
|
||
)
|
||
})
|
||
})
|
||
.is_none_or(|style| !style.bottom_border.invisible),
|
||
format_editable,
|
||
),
|
||
toggle(
|
||
t!("Left border").as_ref(),
|
||
"tbl_cell_border_left",
|
||
table_row
|
||
.and_then(|row| {
|
||
cell.and_then(|cell| {
|
||
style_for_border(
|
||
self,
|
||
row,
|
||
cell_column,
|
||
cell,
|
||
codec::entities::table::CellEdgeFlags::LEFT,
|
||
)
|
||
})
|
||
})
|
||
.is_none_or(|style| !style.left_border.invisible),
|
||
format_editable,
|
||
),
|
||
toggle(
|
||
t!("Locked").as_ref(),
|
||
"tbl_cell_locked",
|
||
cell_locked,
|
||
!immutable_lock,
|
||
),
|
||
],
|
||
});
|
||
}
|
||
|
||
sections.push(
|
||
PropSection {
|
||
title: t!("Table Breaks").into_owned(),
|
||
props: vec![
|
||
toggle(
|
||
t!("Enabled").as_ref(),
|
||
"tbl_break_enabled",
|
||
breaks_enabled,
|
||
true,
|
||
),
|
||
choice(
|
||
t!("Direction").as_ref(),
|
||
"tbl_break_direction",
|
||
match self.break_flow_direction {
|
||
codec::entities::table::BreakFlowDirection::Right => "Right",
|
||
codec::entities::table::BreakFlowDirection::Left => "Left",
|
||
codec::entities::table::BreakFlowDirection::Vertical => "Down",
|
||
}
|
||
.to_string(),
|
||
vec!["Right".into(), "Left".into(), "Down".into()],
|
||
breaks_enabled && !manual_positions,
|
||
),
|
||
toggle(
|
||
t!("Repeat top labels").as_ref(),
|
||
"tbl_break_repeat_top",
|
||
self.break_options
|
||
.contains(BreakOptionFlags::REPEAT_TOP_LABELS),
|
||
breaks_enabled,
|
||
),
|
||
toggle(
|
||
t!("Repeat bottom labels").as_ref(),
|
||
"tbl_break_repeat_bottom",
|
||
self.break_options
|
||
.contains(BreakOptionFlags::REPEAT_BOTTOM_LABELS),
|
||
breaks_enabled,
|
||
),
|
||
toggle(
|
||
t!("Manual positions").as_ref(),
|
||
"tbl_break_manual_positions",
|
||
manual_positions,
|
||
breaks_enabled,
|
||
),
|
||
toggle(
|
||
t!("Manual heights").as_ref(),
|
||
"tbl_break_manual_heights",
|
||
manual_heights,
|
||
breaks_enabled,
|
||
),
|
||
number(
|
||
t!("Maximum height").as_ref(),
|
||
"tbl_break_height",
|
||
break_height,
|
||
breaks_enabled && !manual_heights,
|
||
),
|
||
number(
|
||
t!("Spacing").as_ref(),
|
||
"tbl_break_spacing",
|
||
self.break_spacing,
|
||
breaks_enabled && !manual_positions,
|
||
),
|
||
],
|
||
},
|
||
);
|
||
|
||
sections
|
||
}
|
||
|
||
fn apply_geom_prop(&mut self, field: &str, value: &str) {
|
||
use crate::entities::common::parse_f64;
|
||
use codec::entities::table::{
|
||
BreakOptionFlags, CellStateFlags, CellStyle, CellStylePropertyFlags,
|
||
};
|
||
let flag = match field {
|
||
"tbl_break_enabled" => Some(BreakOptionFlags::ENABLE_BREAKS),
|
||
"tbl_break_repeat_top" => Some(BreakOptionFlags::REPEAT_TOP_LABELS),
|
||
"tbl_break_repeat_bottom" => Some(BreakOptionFlags::REPEAT_BOTTOM_LABELS),
|
||
"tbl_break_manual_positions" => Some(BreakOptionFlags::ALLOW_MANUAL_POSITIONS),
|
||
"tbl_break_manual_heights" => Some(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS),
|
||
_ => None,
|
||
};
|
||
if let Some(flag) = flag {
|
||
if flag != BreakOptionFlags::ENABLE_BREAKS
|
||
&& !self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS)
|
||
{
|
||
return;
|
||
}
|
||
let on = if value == "toggle" {
|
||
!self.break_options.contains(flag)
|
||
} else {
|
||
value == "true"
|
||
};
|
||
self.break_options.set(flag, on);
|
||
self.break_ranges.clear();
|
||
return;
|
||
}
|
||
match field {
|
||
"tbl_title_suppressed" | "tbl_header_suppressed" => {
|
||
let enabled = if value == "toggle" {
|
||
let current = if field == "tbl_title_suppressed" {
|
||
self.legacy_style_override
|
||
.as_ref()
|
||
.and_then(|style| style.title_suppressed)
|
||
.unwrap_or(false)
|
||
} else {
|
||
self.legacy_style_override
|
||
.as_ref()
|
||
.and_then(|style| style.header_suppressed)
|
||
.unwrap_or(false)
|
||
};
|
||
!current
|
||
} else {
|
||
value == "true"
|
||
};
|
||
let style = self.legacy_style_override.get_or_insert_with(Default::default);
|
||
if field == "tbl_title_suppressed" {
|
||
style.flags |= 0x0001;
|
||
style.title_suppressed = Some(enabled);
|
||
} else {
|
||
style.flags |= 0x0002;
|
||
style.header_suppressed = Some(enabled);
|
||
}
|
||
self.override_flag = true;
|
||
return;
|
||
}
|
||
"tbl_flow_direction" => {
|
||
let up = value.trim().eq_ignore_ascii_case("up");
|
||
let style = self.base_style.get_or_insert_with(Default::default);
|
||
style
|
||
.property_flags
|
||
.set(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP, up);
|
||
let legacy = self.legacy_style_override.get_or_insert_with(Default::default);
|
||
legacy.flags |= 0x0004;
|
||
legacy.flow_direction = Some(if up { 1 } else { 0 });
|
||
self.override_flag = true;
|
||
return;
|
||
}
|
||
_ => {}
|
||
}
|
||
let cell_index = prop_current_cell();
|
||
let columns = self.columns.len();
|
||
let cell_position = (columns > 0).then_some((cell_index / columns, cell_index % columns));
|
||
if let Some((row, column)) = cell_position {
|
||
let state = self
|
||
.cell(row, column)
|
||
.map(|cell| cell.state)
|
||
.unwrap_or_default();
|
||
let content_editable = !state.intersects(
|
||
CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY,
|
||
);
|
||
let format_editable = !state.intersects(
|
||
CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY,
|
||
);
|
||
match field {
|
||
"tbl_cell_text" => {
|
||
if !content_editable {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
cell.set_text(value);
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_type" => {
|
||
if !content_editable {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
cell.cell_type = if value.trim().eq_ignore_ascii_case("block") {
|
||
codec::entities::table::CellType::Block
|
||
} else {
|
||
codec::entities::table::CellType::Text
|
||
};
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_alignment" => {
|
||
if !format_editable {
|
||
return;
|
||
}
|
||
let alignment = match value.trim().to_ascii_uppercase().as_str() {
|
||
"TOP LEFT" => 1,
|
||
"TOP CENTER" => 2,
|
||
"TOP RIGHT" => 3,
|
||
"MIDDLE LEFT" => 4,
|
||
"MIDDLE RIGHT" => 6,
|
||
"BOTTOM LEFT" => 7,
|
||
"BOTTOM CENTER" => 8,
|
||
"BOTTOM RIGHT" => 9,
|
||
_ => 5,
|
||
};
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
style.alignment = alignment;
|
||
style.property_flags.insert(
|
||
codec::entities::table::CellStylePropertyFlags::ALIGNMENT,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_text_style" => {
|
||
if !format_editable {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
style.text_style_name = value.trim().to_string();
|
||
style
|
||
.property_flags
|
||
.insert(CellStylePropertyFlags::TEXT_STYLE);
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_rotation" => {
|
||
if !format_editable {
|
||
return;
|
||
}
|
||
if let Some(rotation) = parse_f64(value) {
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
style.rotation = rotation.to_radians();
|
||
style
|
||
.property_flags
|
||
.insert(CellStylePropertyFlags::ROTATION);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_data_type" => {
|
||
if !format_editable {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
if cell.contents.is_empty() {
|
||
cell.set_text("");
|
||
}
|
||
if let Some(content) = cell.contents.first_mut() {
|
||
content.value.value_type = match value.trim().to_ascii_uppercase().as_str() {
|
||
"INTEGER" => codec::entities::table::CellValueType::Long,
|
||
"DECIMAL" => codec::entities::table::CellValueType::Double,
|
||
"DATE" => codec::entities::table::CellValueType::Date,
|
||
"POINT 2D" => codec::entities::table::CellValueType::Point2D,
|
||
"POINT 3D" => codec::entities::table::CellValueType::Point3D,
|
||
"HANDLE" => codec::entities::table::CellValueType::Handle,
|
||
_ => codec::entities::table::CellValueType::String,
|
||
};
|
||
content.value.raw_type_code = content.value.value_type as i32;
|
||
}
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
style
|
||
.property_flags
|
||
.insert(CellStylePropertyFlags::DATA_TYPE);
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_format" => {
|
||
if !format_editable {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
style.value_format = value.to_string();
|
||
style.property_flags.insert(
|
||
codec::entities::table::CellStylePropertyFlags::DATA_FORMAT,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_fill" => {
|
||
if !format_editable {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
style.fill_enabled = if value == "toggle" {
|
||
!style.fill_enabled
|
||
} else {
|
||
value == "true"
|
||
};
|
||
style.property_flags.insert(
|
||
codec::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_border_top"
|
||
| "tbl_cell_border_right"
|
||
| "tbl_cell_border_bottom"
|
||
| "tbl_cell_border_left" => {
|
||
if !format_editable {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
use codec::entities::table::{
|
||
BorderPropertyFlags, CellEdgeFlags,
|
||
};
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
let (border, edge) = match field {
|
||
"tbl_cell_border_top" => (&mut style.top_border, CellEdgeFlags::TOP),
|
||
"tbl_cell_border_right" => {
|
||
(&mut style.right_border, CellEdgeFlags::RIGHT)
|
||
}
|
||
"tbl_cell_border_bottom" => {
|
||
(&mut style.bottom_border, CellEdgeFlags::BOTTOM)
|
||
}
|
||
_ => (&mut style.left_border, CellEdgeFlags::LEFT),
|
||
};
|
||
let visible = if value == "toggle" {
|
||
border.invisible
|
||
} else {
|
||
value == "true"
|
||
};
|
||
border.invisible = !visible;
|
||
border.override_flags.insert(BorderPropertyFlags::INVISIBILITY);
|
||
style.applied_border_edges.insert(edge);
|
||
}
|
||
return;
|
||
}
|
||
"tbl_cell_locked" => {
|
||
if state.intersects(
|
||
CellStateFlags::CONTENT_READ_ONLY | CellStateFlags::FORMAT_READ_ONLY,
|
||
) {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
let locked = if value == "toggle" {
|
||
!cell.state.intersects(
|
||
CellStateFlags::CONTENT_LOCKED | CellStateFlags::FORMAT_LOCKED,
|
||
)
|
||
} else {
|
||
value == "true"
|
||
};
|
||
cell.state.set(CellStateFlags::CONTENT_LOCKED, locked);
|
||
cell.state.set(CellStateFlags::FORMAT_LOCKED, locked);
|
||
}
|
||
return;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let Some(number) = parse_f64(value) else {
|
||
if field == "tbl_break_direction" {
|
||
if !self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS)
|
||
|| self
|
||
.break_options
|
||
.contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS)
|
||
{
|
||
return;
|
||
}
|
||
self.break_flow_direction = match value.trim().to_ascii_uppercase().as_str() {
|
||
"LEFT" => codec::entities::table::BreakFlowDirection::Left,
|
||
"DOWN" | "VERTICAL" => {
|
||
codec::entities::table::BreakFlowDirection::Vertical
|
||
}
|
||
_ => codec::entities::table::BreakFlowDirection::Right,
|
||
};
|
||
self.break_ranges.clear();
|
||
}
|
||
return;
|
||
};
|
||
match field {
|
||
"tbl_insert_x" => self.insertion_point.x = number,
|
||
"tbl_insert_y" => self.insertion_point.y = number,
|
||
"tbl_insert_z" => self.insertion_point.z = number,
|
||
"tbl_direction" => {
|
||
let radians = number.to_radians();
|
||
self.horizontal_direction.x = radians.cos();
|
||
self.horizontal_direction.y = radians.sin();
|
||
self.horizontal_direction.z = 0.0;
|
||
}
|
||
"tbl_rows" => {
|
||
let requested = number.round().max(1.0) as usize;
|
||
// Grow only up to the cell budget (`requested` may be
|
||
// 1e15): the grow loop is an iteration count, not
|
||
// geometry, and used to push rows until the allocator
|
||
// failed. Shrinking to a smaller requested count stays
|
||
// exact — `grow_to` is never above `requested`, so the
|
||
// two loops cannot fight.
|
||
let grow_to = requested.min(MAX_TABLE_CELLS / self.columns.len().max(1));
|
||
while self.rows.len() < grow_to {
|
||
self.add_row();
|
||
}
|
||
while self.rows.len() > requested {
|
||
self.remove_row(self.rows.len() - 1);
|
||
}
|
||
self.break_ranges.clear();
|
||
}
|
||
"tbl_cols" => {
|
||
let requested = number.round().max(1.0) as usize;
|
||
let grow_to = requested.min(MAX_TABLE_CELLS / self.rows.len().max(1));
|
||
let width = self.columns.last().map(|column| column.width).unwrap_or(2.0);
|
||
while self.columns.len() < grow_to {
|
||
self.add_column(width);
|
||
}
|
||
while self.columns.len() > requested {
|
||
self.remove_column(self.columns.len() - 1);
|
||
}
|
||
self.break_ranges.clear();
|
||
}
|
||
"tbl_column_width" if number > 0.0 => {
|
||
for column in &mut self.columns {
|
||
column.width = number;
|
||
}
|
||
self.break_ranges.clear();
|
||
}
|
||
"tbl_row_height" if number > 0.0 => {
|
||
for row in &mut self.rows {
|
||
row.height = number;
|
||
}
|
||
self.break_ranges.clear();
|
||
}
|
||
"tbl_width" if number > 0.0 => {
|
||
let current = self.total_width();
|
||
if current > 1.0e-12 {
|
||
for column in &mut self.columns {
|
||
column.width *= number / current;
|
||
}
|
||
}
|
||
self.break_ranges.clear();
|
||
}
|
||
"tbl_height" if number > 0.0 => {
|
||
let current = self.total_height();
|
||
if current > 1.0e-12 {
|
||
for row in &mut self.rows {
|
||
row.height *= number / current;
|
||
}
|
||
}
|
||
self.break_ranges.clear();
|
||
}
|
||
"tbl_horizontal_margin" if number >= 0.0 => {
|
||
let style = self.base_style.get_or_insert_with(Default::default);
|
||
style.margin_left = number;
|
||
style.margin_right = number;
|
||
style.property_flags.insert(
|
||
CellStylePropertyFlags::MARGIN_LEFT | CellStylePropertyFlags::MARGIN_RIGHT,
|
||
);
|
||
let legacy = self.legacy_style_override.get_or_insert_with(Default::default);
|
||
legacy.flags |= 0x0008;
|
||
legacy.horizontal_cell_margin = Some(number);
|
||
self.override_flag = true;
|
||
}
|
||
"tbl_vertical_margin" if number >= 0.0 => {
|
||
let style = self.base_style.get_or_insert_with(Default::default);
|
||
style.margin_top = number;
|
||
style.margin_bottom = number;
|
||
style.property_flags.insert(
|
||
CellStylePropertyFlags::MARGIN_TOP | CellStylePropertyFlags::MARGIN_BOTTOM,
|
||
);
|
||
let legacy = self.legacy_style_override.get_or_insert_with(Default::default);
|
||
legacy.flags |= 0x0010;
|
||
legacy.vertical_cell_margin = Some(number);
|
||
self.override_flag = true;
|
||
}
|
||
"tbl_cell_text_height" if number > 0.0 => {
|
||
if let Some((row, column)) = cell_position {
|
||
let state = self
|
||
.cell(row, column)
|
||
.map(|cell| cell.state)
|
||
.unwrap_or_default();
|
||
if state.intersects(
|
||
CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY,
|
||
) {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
style.text_height = number;
|
||
style.property_flags.insert(
|
||
codec::entities::table::CellStylePropertyFlags::TEXT_HEIGHT,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
"tbl_cell_margin_left"
|
||
| "tbl_cell_margin_top"
|
||
| "tbl_cell_margin_right"
|
||
| "tbl_cell_margin_bottom"
|
||
if number >= 0.0 =>
|
||
{
|
||
if let Some((row, column)) = cell_position {
|
||
let state = self
|
||
.cell(row, column)
|
||
.map(|cell| cell.state)
|
||
.unwrap_or_default();
|
||
if state.intersects(
|
||
CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY,
|
||
) {
|
||
return;
|
||
}
|
||
if let Some(cell) = self.cell_mut(row, column) {
|
||
let style = cell.style.get_or_insert_with(CellStyle::new);
|
||
let flag = match field {
|
||
"tbl_cell_margin_left" => {
|
||
style.margin_left = number;
|
||
CellStylePropertyFlags::MARGIN_LEFT
|
||
}
|
||
"tbl_cell_margin_top" => {
|
||
style.margin_top = number;
|
||
CellStylePropertyFlags::MARGIN_TOP
|
||
}
|
||
"tbl_cell_margin_right" => {
|
||
style.margin_right = number;
|
||
CellStylePropertyFlags::MARGIN_RIGHT
|
||
}
|
||
_ => {
|
||
style.margin_bottom = number;
|
||
CellStylePropertyFlags::MARGIN_BOTTOM
|
||
}
|
||
};
|
||
style.property_flags.insert(flag);
|
||
}
|
||
}
|
||
}
|
||
"tbl_break_height"
|
||
if number >= 0.0
|
||
&& self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS)
|
||
&& !self
|
||
.break_options
|
||
.contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS) =>
|
||
{
|
||
if self.break_data.is_empty() {
|
||
self.break_data.push(codec::entities::table::TableBreakData {
|
||
position: codec::types::Vector3::ZERO,
|
||
height: number,
|
||
flags: 0,
|
||
});
|
||
} else {
|
||
for data in &mut self.break_data {
|
||
data.height = number;
|
||
}
|
||
}
|
||
self.break_ranges.clear();
|
||
}
|
||
"tbl_break_spacing"
|
||
if number >= 0.0
|
||
&& self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS)
|
||
&& !self
|
||
.break_options
|
||
.contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS) =>
|
||
{
|
||
self.break_spacing = number;
|
||
self.break_ranges.clear();
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Transformable for Table {
|
||
fn apply_transform(&mut self, t: &EntityTransform) {
|
||
transform::apply_standard_entity_transform(self, t, |entity, p1, p2| {
|
||
transform::reflect_xy_point(
|
||
&mut entity.insertion_point.x,
|
||
&mut entity.insertion_point.y,
|
||
p1,
|
||
p2,
|
||
);
|
||
// Reflect the horizontal direction by reflecting a tip point
|
||
let mut tip_x = entity.insertion_point.x + entity.horizontal_direction.x;
|
||
let mut tip_y = entity.insertion_point.y + entity.horizontal_direction.y;
|
||
transform::reflect_xy_point(&mut tip_x, &mut tip_y, p1, p2);
|
||
entity.horizontal_direction.x = tip_x - entity.insertion_point.x;
|
||
entity.horizontal_direction.y = tip_y - entity.insertion_point.y;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Keep the per-cell merge dimensions (what DXF stores) in step with
|
||
/// `merged_ranges` (what DWG stores) after a scripted create or edit. Cells a
|
||
/// script did not touch keep their merge state when the ranges are unchanged.
|
||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||
pub(crate) fn normalize_scripted_table(old: Option<&Table>, new: &mut Table) {
|
||
if old.is_some_and(|old| old.merged_ranges == new.merged_ranges) {
|
||
return;
|
||
}
|
||
for row in &mut new.rows {
|
||
for cell in &mut row.cells {
|
||
cell.merge_width = 1;
|
||
cell.merge_height = 1;
|
||
}
|
||
}
|
||
let ranges = new.merged_ranges.clone();
|
||
for range in &ranges {
|
||
if let Some(cell) = new
|
||
.rows
|
||
.get_mut(range.top_row)
|
||
.and_then(|row| row.cells.get_mut(range.left_col))
|
||
{
|
||
cell.merge_width = range.col_count() as i32;
|
||
cell.merge_height = range.row_count() as i32;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn sample_table() -> Table {
|
||
let mut table = Table::new(codec::types::Vector3::new(0.0, 0.0, 0.0), 2, 2);
|
||
table.set_cell_text(0, 0, "10");
|
||
table.set_cell_text(0, 1, "20");
|
||
table.set_cell_text(1, 0, "30");
|
||
table.set_cell_text(1, 1, "40");
|
||
table
|
||
}
|
||
|
||
/// Delimiters located independently (`find('(')` forward, `rfind(')')`
|
||
/// backward) must not produce a reversed slice: `=)(` used to build
|
||
/// `&body[2..0]` — `slice index starts at 2 but ends at 0` — killing the
|
||
/// render pass from a single pasted cell value.
|
||
#[test]
|
||
fn malformed_formula_is_ignored_instead_of_panicking() {
|
||
let table = sample_table();
|
||
for expression in ["=)(", "=a)b(c", "=)"] {
|
||
assert_eq!(
|
||
evaluate_table_formula(&table, expression),
|
||
None,
|
||
"{expression} should not evaluate"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Well-formed formulas keep evaluating across the whole file — the
|
||
/// guard must only reject malformed delimiters.
|
||
#[test]
|
||
fn well_formed_formulas_still_evaluate() {
|
||
let table = sample_table();
|
||
assert_eq!(
|
||
evaluate_table_formula(&table, "=SUM(A1:B2)"),
|
||
Some("100".to_string())
|
||
);
|
||
assert_eq!(
|
||
evaluate_table_formula(&table, "=AVERAGE(A1:B2)"),
|
||
Some("25".to_string())
|
||
);
|
||
assert_eq!(evaluate_table_formula(&table, "=A1"), Some("10".to_string()));
|
||
assert_eq!(evaluate_table_formula(&table, "not a formula"), None);
|
||
}
|
||
|
||
/// A cell range can name any `usize` — `A1:A18446744073709551615`
|
||
/// parses to row ≈ 1.8e19. `cell()` returns None for out-of-bounds
|
||
/// indices, but the range loop still *walked* every index on the
|
||
/// render path. Clamping the walk to the table's own rows/columns
|
||
/// must collect exactly the same values (each skipped cell would
|
||
/// have been None) and stay instant; the elapsed guard keeps a
|
||
/// future un-clamped loop from creeping back in.
|
||
#[test]
|
||
fn formula_range_evaluation_is_bounded_by_the_table() {
|
||
let table = sample_table();
|
||
let start = std::time::Instant::now();
|
||
assert_eq!(
|
||
evaluate_table_formula(&table, "=SUM(A1:A18446744073709551615)"),
|
||
Some("40".to_string()),
|
||
"A1:A… is column A only: 10 + 30"
|
||
);
|
||
assert_eq!(
|
||
evaluate_table_formula(&table, "=COUNT(A1:A1000000000)"),
|
||
Some("2".to_string()),
|
||
"column A holds two numeric cells"
|
||
);
|
||
assert_eq!(
|
||
evaluate_table_formula(&table, "=SUM(A1:Z999999999)"),
|
||
Some("100".to_string())
|
||
);
|
||
assert_eq!(
|
||
evaluate_table_formula(&table, "=AVERAGE(A1:ZZ18446744073709551615)"),
|
||
Some("25".to_string())
|
||
);
|
||
let elapsed = start.elapsed();
|
||
assert!(
|
||
elapsed < std::time::Duration::from_secs(5),
|
||
"formula evaluation took {elapsed:?} — the range walk is unbounded again"
|
||
);
|
||
}
|
||
|
||
/// A column count driven to `MAX_TYPED_MAGNITUDE` (1e15) grows one
|
||
/// column per loop iteration until the allocator fails: the magnitude
|
||
/// check bounds geometry, not iteration counts. Bound the cell grid
|
||
/// instead — column growth is limited by the rows already present.
|
||
#[test]
|
||
fn tbl_column_count_stays_within_budget() {
|
||
let mut table = Table::new(codec::types::Vector3::new(0.0, 0.0, 0.0), 1, 1);
|
||
let cap = MAX_TABLE_CELLS / table.rows.len().max(1);
|
||
table.apply_geom_prop("tbl_cols", &(cap + 10).to_string());
|
||
assert!(
|
||
table.columns.len() <= cap,
|
||
"tbl_cols grew to {} columns (budget {} cells over {} rows)",
|
||
table.columns.len(),
|
||
MAX_TABLE_CELLS,
|
||
table.rows.len()
|
||
);
|
||
table.apply_geom_prop("tbl_cols", "1000000000000000");
|
||
assert!(
|
||
table.columns.len() <= cap,
|
||
"tbl_cols grew to {} columns from a hostile value",
|
||
table.columns.len()
|
||
);
|
||
}
|
||
|
||
/// Same budget applied to row growth: with 10 columns the table may
|
||
/// hold `MAX_TABLE_CELLS / 10` rows, never the requested 1e15.
|
||
#[test]
|
||
fn tbl_row_count_stays_within_budget() {
|
||
let mut table = Table::new(codec::types::Vector3::new(0.0, 0.0, 0.0), 1, 1);
|
||
table.apply_geom_prop("tbl_cols", "10");
|
||
let cap = MAX_TABLE_CELLS / table.columns.len().max(1);
|
||
table.apply_geom_prop("tbl_rows", &(cap + 10).to_string());
|
||
assert!(
|
||
table.rows.len() <= cap,
|
||
"tbl_rows grew to {} rows (budget {} cells over {} columns)",
|
||
table.rows.len(),
|
||
MAX_TABLE_CELLS,
|
||
table.columns.len()
|
||
);
|
||
table.apply_geom_prop("tbl_rows", "1000000000000000");
|
||
assert!(
|
||
table.rows.len() <= cap,
|
||
"tbl_rows grew to {} rows from a hostile value",
|
||
table.rows.len()
|
||
);
|
||
}
|
||
}
|