forked from 1b-agent/opencadstudio-upstream
The codec and kernel repositories and crates are renamed opencadcodec and opencadkernel. Inside OCS they are depended on under short keys, so code reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node graph engine). File-format and protocol spellings stay as they were: the ACIS product id `acadrust`, the ACADRUST_* switches and the plugin version keys `acadrust_version` / `acadrust_source`. A button left of MCP on the command line opens a translucent node graph over the viewport, with a palette of object, input, math, logic, list, text and geometry nodes added by click or drag. An object node owns drawing entities and its rows are the Properties panel's own rows for them: the single-object page is extracted from refresh_properties as entity_property_sections, values use the control protocol's property_json, and writes go through set_property_value with a property target override, so the panel, MCP and the graph share one read/write path. A list reaching an object row makes one entity per element. Each graph action is one undo step; the canvas pans and zooms. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1063 lines
42 KiB
Rust
1063 lines
42 KiB
Rust
use codec::entities::Tolerance;
|
|
|
|
use crate::command::EntityTransform;
|
|
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip};
|
|
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
|
|
use crate::scene::convert::acad_to_render::{
|
|
GlyphRun, RenderEntity, RenderObject, TextPlane, TextStroke,
|
|
};
|
|
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
|
|
use crate::scene::model::wire_model::SnapHint;
|
|
use crate::scene::text::lff;
|
|
use crate::scene::view::transform;
|
|
use crate::t;
|
|
|
|
// ── GDT text parser ───────────────────────────────────────────────────────────
|
|
|
|
/// The font a compartment's symbols come from.
|
|
///
|
|
/// The source names it in its own escapes, and it is registered under that name
|
|
/// like any other font — so nothing here special-cases it. A letter in this font
|
|
/// IS a symbol; it holds no Latin text.
|
|
const SYMBOL_FONT: &str = "gdt";
|
|
/// The font a compartment's ordinary text comes from.
|
|
const TEXT_FONT: &str = "txt";
|
|
|
|
/// One stretch of a compartment drawn in a single font.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct Run {
|
|
text: String,
|
|
font: &'static str,
|
|
}
|
|
|
|
/// A compartment: the runs it is built from, in order.
|
|
type Cell = Vec<Run>;
|
|
|
|
/// The characters of `cell`, whichever font each came from. For messages and
|
|
/// tests — never for measuring, since the runs are in different fonts.
|
|
#[cfg(test)]
|
|
fn cell_text(cell: &Cell) -> String {
|
|
cell.iter().map(|r| r.text.as_str()).collect()
|
|
}
|
|
|
|
/// Parse a tolerance text string into rows of compartments.
|
|
///
|
|
/// Example: `{\Fgdt;p}%%v0.5%%vA%%vB%%v%%v` + newline + `{\Fgdt;j}%%v0.1%%vA`
|
|
/// - row separator → see below; `%%v` → compartment separator within a row
|
|
/// - `{\Fgdt;X}` → character X, drawn from the font that escape names
|
|
///
|
|
/// The row break reaches us in four encodings and all four are live:
|
|
/// a DWG carries a raw newline; the text-DXF reader rewrites the on-disk `^J`
|
|
/// to a newline before we ever see it; the binary-DXF reader does not, so `^J`
|
|
/// can still arrive literally; and our own DXF writer re-emits embedded
|
|
/// newlines as `\P`, because a line-based format cannot carry a raw newline
|
|
/// inside a string value. Splitting on `^J` alone — as this did — therefore
|
|
/// never fired on the two mainstream paths, and every row collapsed into one.
|
|
fn parse_gdt_rows(raw: &str) -> Vec<Vec<Cell>> {
|
|
let norm = raw
|
|
.replace("^J", "\n")
|
|
.replace("\\P", "\n")
|
|
.replace("\r\n", "\n")
|
|
.replace('\r', "\n");
|
|
|
|
norm.split('\n')
|
|
.filter(|row| !row.trim().is_empty())
|
|
.map(|row| {
|
|
let mut cells: Vec<Cell> = row.split("%%v").map(|c| parse_cell(c.trim())).collect();
|
|
// Trailing empties are unused compartments, not blank boxes: a row
|
|
// written with spare datum slots ends in `%%v%%v` yet draws only the
|
|
// compartments it filled. Interior empties stay — there the position
|
|
// itself carries meaning (datum A, _, C).
|
|
while cells.last().is_some_and(|c| c.is_empty()) {
|
|
cells.pop();
|
|
}
|
|
cells
|
|
})
|
|
.filter(|cells: &Vec<Cell>| !cells.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
/// Split a compartment into font runs, resolving `{\Fgdt;X}` switches and
|
|
/// dropping every other inline format code.
|
|
///
|
|
/// The escape names its font and carries the character to draw in it; both are
|
|
/// kept as-is, so the symbol is looked up by the ordinary font machinery rather
|
|
/// than translated into some other character first.
|
|
fn parse_cell(s: &str) -> Cell {
|
|
let mut runs: Cell = Vec::new();
|
|
let push = |ch: char, font: &'static str, runs: &mut Cell| match runs.last_mut() {
|
|
Some(r) if r.font == font => r.text.push(ch),
|
|
_ => runs.push(Run {
|
|
text: ch.to_string(),
|
|
font,
|
|
}),
|
|
};
|
|
|
|
let mut chars = s.chars().peekable();
|
|
while let Some(ch) = chars.next() {
|
|
if ch != '{' {
|
|
push(ch, TEXT_FONT, &mut runs);
|
|
continue;
|
|
}
|
|
// Collect to the closing brace.
|
|
let mut inner = String::new();
|
|
let mut depth = 1usize;
|
|
for c in chars.by_ref() {
|
|
match c {
|
|
'{' => {
|
|
depth += 1;
|
|
inner.push(c);
|
|
}
|
|
'}' => {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
break;
|
|
}
|
|
inner.push(c);
|
|
}
|
|
_ => inner.push(c),
|
|
}
|
|
}
|
|
if let Some(sym) = symbol_font_switch(&inner) {
|
|
push(sym, SYMBOL_FONT, &mut runs);
|
|
}
|
|
// Any other format code contributes nothing.
|
|
}
|
|
runs
|
|
}
|
|
|
|
/// The character selected by a `\F<symbol font>;X` switch, or `None` when the
|
|
/// braces hold some other format code.
|
|
///
|
|
/// The font name may carry an extension and a run of `|`-separated parameters
|
|
/// (`\Fgdt.shx|b0|i0|c134|p6;j`), so match the name and skip to the `;` rather
|
|
/// than demanding the bare `\Fgdt;` form — the parameterised spelling is legal
|
|
/// and would otherwise drop the symbol silently.
|
|
pub(crate) fn symbol_font_switch(inner: &str) -> Option<char> {
|
|
let rest = inner
|
|
.strip_prefix("\\F")
|
|
.or_else(|| inner.strip_prefix("\\f"))?;
|
|
let (name, tail) = rest.split_once(';')?;
|
|
let name = name.split('|').next()?.trim();
|
|
let stem = name.strip_suffix(".shx").unwrap_or(name);
|
|
if !stem.eq_ignore_ascii_case("gdt") {
|
|
return None;
|
|
}
|
|
tail.chars().next()
|
|
}
|
|
|
|
/// The tolerance's dimension style — by handle first, then by name.
|
|
///
|
|
/// The order matters and is not interchangeable: a DWG records the style's
|
|
/// handle and leaves the name at its default, while a DXF records the name and
|
|
/// leaves the handle empty. Matching on the name alone — the shape used
|
|
/// elsewhere for entities that only ever carry one — would silently resolve
|
|
/// every DWG-read tolerance to "Standard" and pick the wrong metrics.
|
|
pub(crate) fn resolve_dim_style<'a>(
|
|
tol: &Tolerance,
|
|
doc: &'a codec::CadDocument,
|
|
) -> Option<&'a codec::tables::DimStyle> {
|
|
if let Some(h) = tol.dimension_style_handle {
|
|
if !h.is_null() {
|
|
if let Some(s) = doc.dim_styles.iter().find(|s| s.handle == h) {
|
|
return Some(s);
|
|
}
|
|
}
|
|
}
|
|
let name = tol.dimension_style_name.trim();
|
|
if name.is_empty() {
|
|
return None;
|
|
}
|
|
doc.dim_styles
|
|
.iter()
|
|
.find(|s| s.name.eq_ignore_ascii_case(name))
|
|
}
|
|
|
|
// ── Feature-control frame builder ─────────────────────────────────────────────
|
|
|
|
/// The pen advance of `text` in `font` at `height`.
|
|
///
|
|
/// This is where the pen LANDS, so it carries the font's letter spacing past
|
|
/// the final glyph — which is what puts the gap between one run and the next.
|
|
fn run_advance(
|
|
text: &str,
|
|
font: &str,
|
|
height: f32,
|
|
width_factor: f32,
|
|
oblique: f32,
|
|
) -> f32 {
|
|
crate::entities::text_support::text_local_bounds(
|
|
font,
|
|
text,
|
|
height,
|
|
width_factor,
|
|
oblique,
|
|
)
|
|
.map(|b| b.advance)
|
|
.unwrap_or(0.0)
|
|
}
|
|
|
|
/// The font's letter spacing at `height`, in world units.
|
|
///
|
|
/// Glyph geometry is authored against a 9-unit cap height, so a font's spacing
|
|
/// scales with the character height like everything else.
|
|
fn letter_spacing(font: &str, height: f32, width_factor: f32) -> f32 {
|
|
crate::scene::text::font_face::Face::resolve(font).letter_spacing()
|
|
* height
|
|
/ 9.0
|
|
* width_factor
|
|
}
|
|
|
|
/// How wide a compartment's content actually draws.
|
|
///
|
|
/// Not the pen advance: that stops one letter-space PAST the last glyph, since
|
|
/// its job is to place whatever comes next. Summing run advances therefore
|
|
/// over-measures by exactly one spacing — the gaps between runs are real, the
|
|
/// one hanging off the end is not — and every compartment came out that much
|
|
/// too wide.
|
|
fn content_width(
|
|
cell: &Cell,
|
|
height: f32,
|
|
text_style: &crate::entities::text_support::ResolvedTextStyle,
|
|
) -> f32 {
|
|
let Some(last) = cell.last() else {
|
|
return 0.0;
|
|
};
|
|
let pen: f32 = cell
|
|
.iter()
|
|
.map(|run| {
|
|
let (font, width, oblique) = if run.font == SYMBOL_FONT {
|
|
(SYMBOL_FONT, 1.0, 0.0)
|
|
} else {
|
|
(
|
|
text_style.font_name.as_str(),
|
|
text_style.width_factor,
|
|
text_style.oblique_angle,
|
|
)
|
|
};
|
|
run_advance(&run.text, font, height, width, oblique)
|
|
})
|
|
.sum();
|
|
let (last_font, last_width) = if last.font == SYMBOL_FONT {
|
|
(SYMBOL_FONT, 1.0)
|
|
} else {
|
|
(text_style.font_name.as_str(), text_style.width_factor)
|
|
};
|
|
(pen - letter_spacing(last_font, height, last_width)).max(0.0)
|
|
}
|
|
|
|
/// One text run of a feature-control frame, ready to become a `TextStroke`
|
|
/// with a `GlyphRun` so the run can render as SDF glyph quads (or, when
|
|
/// SDF is off, from `strokes`). `origin` is relative to the tolerance insertion
|
|
/// point; `strokes` remain in the frame's local plane.
|
|
struct TolCell {
|
|
text: String,
|
|
font: String,
|
|
origin: [f32; 2],
|
|
strokes: Vec<Vec<[f32; 2]>>,
|
|
height: f32,
|
|
width_factor: f32,
|
|
oblique: f32,
|
|
}
|
|
|
|
/// Tessellate a Tolerance entity's feature-control frame.
|
|
///
|
|
/// Returns (`box_strokes`, `cells`):
|
|
/// - `box_strokes` — the outer border, row separators and column dividers in
|
|
/// frame-local coordinates
|
|
/// - `cells` — one [`TolCell`] per non-empty cell, carrying its text + a
|
|
/// `GlyphRun` so the cell renders as SDF text (frame stays geometry).
|
|
fn tessellate_tolerance(
|
|
tol: &Tolerance,
|
|
doc: &codec::CadDocument,
|
|
) -> (Vec<Vec<[f32; 2]>>, Vec<TolCell>) {
|
|
if tol.text.is_empty() {
|
|
return (vec![], vec![]);
|
|
}
|
|
|
|
let rows = parse_gdt_rows(&tol.text);
|
|
if rows.is_empty() {
|
|
return (vec![], vec![]);
|
|
}
|
|
|
|
// ── Metrics ──────────────────────────────────────────────────────────
|
|
// Text height and gap come from the dimension style whenever one resolves.
|
|
// The entity's own fields are not trustworthy: a DWG carries only the
|
|
// style's handle and leaves these at their constructed defaults (0.18 /
|
|
// 0.09), which are small but non-zero — so a "> 0" test happily uses them
|
|
// and the frame draws about ten times too small. Falling back to them only
|
|
// when no style resolves keeps DXF-read entities working.
|
|
let style = resolve_dim_style(tol, doc);
|
|
let xd = &tol.common.extended_data;
|
|
let scale = crate::entities::dim_override::real(xd, crate::entities::dim_override::DIMSCALE)
|
|
.or_else(|| style.map(|s| s.dimscale))
|
|
.map(|value| if value > 1e-6 { value } else { 1.0 })
|
|
.unwrap_or(1.0);
|
|
// The entity's own overrides win over its style; the style wins over the
|
|
// entity's constructed defaults.
|
|
let h = crate::entities::dim_override::real(xd, crate::entities::dim_override::DIMTXT)
|
|
.or(style.map(|s| s.dimtxt))
|
|
.map(|v| v * scale)
|
|
.unwrap_or(tol.text_height) as f32;
|
|
let h = if h > 1e-6 { h } else { 2.5_f32 };
|
|
|
|
let text_style_name = crate::entities::dim_override::handle(
|
|
xd,
|
|
crate::entities::dim_override::DIMTXSTY,
|
|
)
|
|
.and_then(|handle| doc.text_styles.iter().find(|entry| entry.handle == handle))
|
|
.map(|entry| entry.name.as_str())
|
|
.or_else(|| style.map(|style| style.dimtxsty.as_str()))
|
|
.unwrap_or("Standard");
|
|
let text_style = crate::entities::text_support::resolve_text_style(text_style_name, doc);
|
|
|
|
// A compartment is a PROPORTION of the character height: it spans -h..+h
|
|
// about the row's centreline, so it is exactly 2h tall and the h/2 margin
|
|
// falls out of that rather than being an input.
|
|
//
|
|
// The gap variable is deliberately NOT read. Its scope is the dimension
|
|
// line's break and the rectangle around a basic dimension — a different
|
|
// entity's geometry, where it genuinely does apply twice per axis. Carrying
|
|
// that shape over to this frame drew it at ~4x the character height. Even
|
|
// fed a self-consistent style it can only ever produce 1.5h, never 2h, so
|
|
// the formula was wrong in kind, not merely mis-fed.
|
|
let cell_h = 2.0 * h;
|
|
let pad = h * 0.5;
|
|
let min_cell_w = 2.0 * h;
|
|
|
|
// Cell widths per row, not a shared column grid: a row holding one
|
|
// compartment is only as wide as that compartment, so a lone datum below a
|
|
// four-compartment row draws a small box rather than inheriting the row
|
|
// above it. Width comes from the real pen advance — a character count times
|
|
// an average width mis-measures every symbol, and counting UTF-8 bytes
|
|
// (`len()`) would make each one two or three cells wide.
|
|
// A compartment's runs come from different fonts, so each is measured in its
|
|
// own before they are summed.
|
|
let cell_width = |cell: &Cell| -> f32 {
|
|
(content_width(cell, h, &text_style) + 2.0 * pad).max(min_cell_w)
|
|
};
|
|
let row_widths: Vec<Vec<f32>> = rows
|
|
.iter()
|
|
.map(|row| row.iter().map(|c| cell_width(c)).collect())
|
|
.collect();
|
|
|
|
let mut box_out: Vec<Vec<[f32; 2]>> = Vec::new();
|
|
let mut cells: Vec<TolCell> = Vec::new();
|
|
|
|
// The insertion point sits on the CENTRELINE OF THE FIRST ROW, and rows
|
|
// stack downward from there: row `i` spans `-h - 2h*i` .. `+h - 2h*i`. So
|
|
// row 0 straddles y = 0 and the frame grows downward only.
|
|
//
|
|
// Anchoring the frame's bottom-left at the insertion point instead — as
|
|
// this did — offsets the whole frame upward by its own height, and by an
|
|
// amount that changes with the number of rows, so a two-row frame and a
|
|
// three-row one would sit at different heights from the same point.
|
|
let row_bottom = |ri: usize| -h - cell_h * ri as f32;
|
|
|
|
// ── Per-row border ────────────────────────────────────────────────────
|
|
// One box per row, each left-aligned at x = 0 and only as wide as its own
|
|
// compartments. Adjacent rows share the edge between them, so this draws
|
|
// the row separators too.
|
|
for (ri, widths) in row_widths.iter().enumerate() {
|
|
let rw: f32 = widths.iter().sum();
|
|
let y0 = row_bottom(ri);
|
|
let y1 = y0 + cell_h;
|
|
box_out.push(vec![
|
|
[0.0, y0],
|
|
[rw, y0],
|
|
[rw, y1],
|
|
[0.0, y1],
|
|
[0.0, y0],
|
|
]);
|
|
}
|
|
|
|
// ── Compartment dividers, within each row ─────────────────────────────
|
|
for (ri, widths) in row_widths.iter().enumerate() {
|
|
let y0 = row_bottom(ri);
|
|
let y1 = y0 + cell_h;
|
|
let mut x_cursor = 0.0_f32;
|
|
for w in widths.iter().take(widths.len().saturating_sub(1)) {
|
|
x_cursor += w;
|
|
box_out.push(vec![[x_cursor, y0], [x_cursor, y1]]);
|
|
}
|
|
}
|
|
|
|
// ── Text content per cell ─────────────────────────────────────────────
|
|
// Each cell stays in frame-local coordinates; the renderer maps the frame
|
|
// into its world-space plane.
|
|
for (ri, row) in rows.iter().enumerate() {
|
|
// The compartment is 2h tall and the character h, so an h/2 margin
|
|
// centres the text on the row's centreline.
|
|
let row_y = row_bottom(ri) + pad;
|
|
let mut cell_x = 0.0_f32;
|
|
for (ci, cell) in row.iter().enumerate() {
|
|
let cw = row_widths[ri][ci];
|
|
if !cell.is_empty() {
|
|
// Centre the compartment's whole content, then lay its runs out
|
|
// left to right — each in its own font, each advancing the pen
|
|
// by what that font actually measures.
|
|
let mut run_x = cell_x + (cw - content_width(cell, h, &text_style)) * 0.5;
|
|
for run in cell {
|
|
let text = run.text.clone();
|
|
let (font, width_factor, oblique) = if run.font == SYMBOL_FONT {
|
|
(SYMBOL_FONT.to_string(), 1.0, 0.0)
|
|
} else {
|
|
(
|
|
text_style.font_name.clone(),
|
|
text_style.width_factor,
|
|
text_style.oblique_angle,
|
|
)
|
|
};
|
|
let (local_strokes, _) =
|
|
lff::tessellate_text_ex(
|
|
[0.0, 0.0],
|
|
h,
|
|
0.0,
|
|
width_factor,
|
|
oblique,
|
|
&font,
|
|
&text,
|
|
);
|
|
let strokes: Vec<Vec<[f32; 2]>> = local_strokes
|
|
.into_iter()
|
|
.filter(|pl: &Vec<[f32; 2]>| !pl.is_empty())
|
|
.collect();
|
|
let advance = run_advance(&text, &font, h, width_factor, oblique);
|
|
cells.push(TolCell {
|
|
text,
|
|
font,
|
|
origin: [run_x, row_y],
|
|
strokes,
|
|
height: h,
|
|
width_factor,
|
|
oblique,
|
|
});
|
|
run_x += advance;
|
|
}
|
|
}
|
|
cell_x += cw;
|
|
}
|
|
}
|
|
|
|
(box_out, cells)
|
|
}
|
|
|
|
pub(crate) fn preview_strokes(
|
|
tol: &Tolerance,
|
|
doc: &codec::CadDocument,
|
|
) -> Vec<Vec<[f32; 2]>> {
|
|
tessellate_tolerance(tol, doc).0
|
|
}
|
|
|
|
fn frame_basis(tol: &Tolerance) -> (glam::DVec3, glam::DVec3) {
|
|
let normal = glam::DVec3::new(tol.normal.x, tol.normal.y, tol.normal.z)
|
|
.normalize_or(glam::DVec3::Z);
|
|
let direction = glam::DVec3::new(tol.direction.x, tol.direction.y, tol.direction.z);
|
|
let fallback = crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)).0;
|
|
let fallback = glam::DVec3::new(fallback.0, fallback.1, fallback.2);
|
|
let x_axis = (direction - normal * direction.dot(normal)).normalize_or(fallback);
|
|
let y_axis = normal.cross(x_axis).normalize_or(glam::DVec3::Y);
|
|
(x_axis, y_axis)
|
|
}
|
|
|
|
// ── RenderConvertible ──────────────────────────────────────────────────────────
|
|
|
|
impl RenderConvertible for Tolerance {
|
|
fn to_render(&self, document: &codec::CadDocument) -> Option<RenderEntity> {
|
|
if self.text.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let snap_pt = glam::DVec3::new(
|
|
self.insertion_point.x,
|
|
self.insertion_point.y,
|
|
self.insertion_point.z,
|
|
);
|
|
|
|
// Build the feature-control frame in local space; origin stored as f64.
|
|
let (box_strokes, cells) = tessellate_tolerance(self, document);
|
|
let ins = glam::DVec3::new(
|
|
self.insertion_point.x,
|
|
self.insertion_point.y,
|
|
self.insertion_point.z,
|
|
);
|
|
let (x_axis, y_axis) = frame_basis(self);
|
|
let plane = |origin: glam::DVec3| TextPlane {
|
|
origin: origin.to_array(),
|
|
scale_origin: ins.to_array(),
|
|
x_axis: x_axis.to_array(),
|
|
y_axis: y_axis.to_array(),
|
|
};
|
|
let style = resolve_dim_style(self, document);
|
|
let xd = &self.common.extended_data;
|
|
let explicit_color = |index: Option<i16>| {
|
|
index.filter(|value| !matches!(value, 0 | 256)).map(|value| {
|
|
let [red, green, blue, _] = crate::scene::convert::tess_util::aci_to_rgba(
|
|
&codec::types::Color::from_index(value),
|
|
);
|
|
[red, green, blue]
|
|
})
|
|
};
|
|
let color_value = |color: &codec::types::Color| {
|
|
let [red, green, blue, _] =
|
|
crate::scene::convert::tess_util::aci_to_rgba(color);
|
|
[red, green, blue]
|
|
};
|
|
let frame_color = if let Some(index) = crate::entities::dim_override::int(
|
|
xd,
|
|
crate::entities::dim_override::DIMCLRD,
|
|
) {
|
|
explicit_color(Some(index))
|
|
} else if let Some(color) =
|
|
style.and_then(|entry| entry.dimclrd_true_color.as_ref())
|
|
{
|
|
Some(color_value(color))
|
|
} else {
|
|
explicit_color(style.map(|entry| entry.dimclrd))
|
|
};
|
|
let text_color = if let Some(index) = crate::entities::dim_override::int(
|
|
xd,
|
|
crate::entities::dim_override::DIMCLRT,
|
|
) {
|
|
explicit_color(Some(index))
|
|
} else if let Some(color) =
|
|
style.and_then(|entry| entry.dimclrt_true_color.as_ref())
|
|
{
|
|
Some(color_value(color))
|
|
} else {
|
|
explicit_color(style.map(|entry| entry.dimclrt))
|
|
};
|
|
|
|
// Frame geometry first (run-less → always strokes; also the anchor
|
|
// group so its origin = the insertion point), then one run-group per
|
|
// text cell so the cell text renders as SDF glyphs (or strokes when
|
|
// SDF is off).
|
|
let mut groups: Vec<TextStroke> = Vec::with_capacity(1 + cells.len());
|
|
if !box_strokes.is_empty() {
|
|
groups.push(TextStroke {
|
|
strokes: box_strokes,
|
|
origin: [ins.x, ins.y],
|
|
color: frame_color,
|
|
fill_tris: vec![],
|
|
plane: Some(plane(ins)),
|
|
run: None,
|
|
});
|
|
}
|
|
for cell in cells {
|
|
groups.push(TextStroke {
|
|
strokes: cell.strokes,
|
|
origin: [
|
|
ins.x + x_axis.x * cell.origin[0] as f64
|
|
+ y_axis.x * cell.origin[1] as f64,
|
|
ins.y + x_axis.y * cell.origin[0] as f64
|
|
+ y_axis.y * cell.origin[1] as f64,
|
|
],
|
|
color: text_color,
|
|
fill_tris: vec![],
|
|
plane: Some(plane(
|
|
ins + x_axis * cell.origin[0] as f64 + y_axis * cell.origin[1] as f64,
|
|
)),
|
|
run: Some(GlyphRun {
|
|
text: cell.text,
|
|
font: cell.font,
|
|
height: cell.height,
|
|
rotation: 0.0,
|
|
width_factor: cell.width_factor,
|
|
oblique: cell.oblique,
|
|
// `tracking` scales the font's own letter spacing, so 0
|
|
// collapses the gap between glyphs and the characters run
|
|
// together. Every other text-bearing entity passes 1.0, and
|
|
// so do both the stroke path (`tessellate_text_ex`) and the
|
|
// width measurement above — leaving this at 0 made the cells
|
|
// measure wider than the text they drew.
|
|
tracking: 1.0,
|
|
bold: false,
|
|
}),
|
|
});
|
|
}
|
|
if groups.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(RenderEntity {
|
|
pick_tris: Vec::new(),
|
|
object: RenderObject::Text(groups),
|
|
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
|
|
tangent_geoms: vec![],
|
|
key_vertices: vec![],
|
|
fill_tris: vec![],
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Grippable ─────────────────────────────────────────────────────────────────
|
|
|
|
impl Grippable for Tolerance {
|
|
fn grips(&self) -> Vec<GripDef> {
|
|
vec![square_grip(
|
|
0,
|
|
glam::DVec3::new(
|
|
self.insertion_point.x,
|
|
self.insertion_point.y,
|
|
self.insertion_point.z,
|
|
),
|
|
)]
|
|
}
|
|
|
|
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
|
|
if grip_id == 0 {
|
|
match apply {
|
|
GripApply::Translate(d) => {
|
|
self.insertion_point.x += d.x as f64;
|
|
self.insertion_point.y += d.y as f64;
|
|
self.insertion_point.z += d.z as f64;
|
|
}
|
|
GripApply::Absolute(p) => {
|
|
self.insertion_point.x = p.x as f64;
|
|
self.insertion_point.y = p.y as f64;
|
|
self.insertion_point.z = p.z as f64;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── PropertyEditable ──────────────────────────────────────────────────────────
|
|
|
|
impl PropertyEditable for Tolerance {
|
|
fn geometry_properties(&self, text_style_names: &[String]) -> Vec<PropSection> {
|
|
vec![
|
|
PropSection {
|
|
title: t!("Text").into_owned(),
|
|
props: vec![
|
|
Property {
|
|
label: t!("Text style").into_owned(),
|
|
field: "tol_text_style",
|
|
value: PropValue::Choice {
|
|
selected: String::new(),
|
|
options: text_style_names.to_vec(),
|
|
},
|
|
},
|
|
edit(t!("Text height").as_ref(), "tol_text_height", self.text_height),
|
|
],
|
|
},
|
|
PropSection {
|
|
title: t!("Geometry").into_owned(),
|
|
props: vec![
|
|
edit(t!("Position X").as_ref(), "tol_ix", self.insertion_point.x),
|
|
edit(t!("Position Y").as_ref(), "tol_iy", self.insertion_point.y),
|
|
edit(t!("Position Z").as_ref(), "tol_iz", self.insertion_point.z),
|
|
],
|
|
},
|
|
PropSection {
|
|
title: t!("Misc").into_owned(),
|
|
props: vec![ro(
|
|
t!("Dim style").as_ref(),
|
|
"tol_dim_style",
|
|
if self.dimension_style_name.is_empty() {
|
|
"(default)".to_string()
|
|
} else {
|
|
self.dimension_style_name.clone()
|
|
},
|
|
)],
|
|
},
|
|
]
|
|
}
|
|
|
|
fn apply_geom_prop(&mut self, field: &str, value: &str) {
|
|
let Ok(v) = value.trim().parse::<f64>() else {
|
|
return;
|
|
};
|
|
match field {
|
|
"tol_ix" => self.insertion_point.x = v,
|
|
"tol_iy" => self.insertion_point.y = v,
|
|
"tol_iz" => self.insertion_point.z = v,
|
|
"tol_text_height" if v > 0.0 => self.text_height = v,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Transformable ─────────────────────────────────────────────────────────────
|
|
|
|
impl Transformable for Tolerance {
|
|
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,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The benchmark's own string, byte for byte, in the form a DWG carries it
|
|
/// (a raw newline). This one assertion is the whole reported bug: it used to
|
|
/// collapse to a single row of ASCII soup.
|
|
const BENCH: &str =
|
|
"{\\Fgdt;r}%%v{\\Fgdt;n}tol{\\Fgdt;m}%%v{\\Fgdt;n}tol{\\Fgdt;s}%%v1{\\Fgdt;m}%%v%%v\n2{\\Fgdt;p}\nA";
|
|
|
|
#[test]
|
|
fn the_benchmark_frame_parses_to_four_one_one() {
|
|
let rows = parse_gdt_rows(BENCH);
|
|
// Rendered as "<font>:<text>" per run so a wrong font fails loudly.
|
|
let shown: Vec<Vec<String>> = rows
|
|
.iter()
|
|
.map(|row| {
|
|
row.iter()
|
|
.map(|cell| {
|
|
cell.iter()
|
|
.map(|r| format!("{}:{}", r.font, r.text))
|
|
.collect::<Vec<_>>()
|
|
.join("|")
|
|
})
|
|
.collect()
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
shown,
|
|
vec![
|
|
vec![
|
|
"gdt:r".to_string(),
|
|
"gdt:n|txt:tol|gdt:m".to_string(),
|
|
"gdt:n|txt:tol|gdt:s".to_string(),
|
|
"txt:1|gdt:m".to_string(),
|
|
],
|
|
vec!["txt:2|gdt:p".to_string()],
|
|
vec!["txt:A".to_string()],
|
|
],
|
|
"the reference draws [◎][⌀tolⓂ][⌀tolⓈ][1Ⓜ] / [2Ⓟ] / [A], each symbol \
|
|
from the font its escape names"
|
|
);
|
|
}
|
|
|
|
/// A row break reaches us in four encodings depending on the format and who
|
|
/// wrote it. All four must land on the same frame.
|
|
#[test]
|
|
fn every_row_break_encoding_gives_the_same_rows() {
|
|
let want = parse_gdt_rows(BENCH);
|
|
assert_eq!(want.len(), 3, "guard: the baseline itself must be 3 rows");
|
|
for (label, variant) in [
|
|
("^J", BENCH.replace('\n', "^J")),
|
|
("\\P", BENCH.replace('\n', "\\P")),
|
|
("CRLF", BENCH.replace('\n', "\r\n")),
|
|
("CR", BENCH.replace('\n', "\r")),
|
|
] {
|
|
assert_eq!(parse_gdt_rows(&variant), want, "{label} parsed differently");
|
|
}
|
|
}
|
|
|
|
/// Spare compartments at the end of a row are unused slots, not empty boxes
|
|
/// — the benchmark's first row ends `%%v%%v` yet draws four.
|
|
#[test]
|
|
fn trailing_empty_compartments_are_dropped_interior_ones_kept() {
|
|
let shown = |raw: &str| -> Vec<Vec<String>> {
|
|
parse_gdt_rows(raw)
|
|
.iter()
|
|
.map(|row| row.iter().map(cell_text).collect())
|
|
.collect()
|
|
};
|
|
assert_eq!(shown("a%%vb%%v%%v"), vec![vec!["a", "b"]]);
|
|
assert_eq!(shown("a%%v%%vc"), vec![vec!["a", "", "c"]]);
|
|
}
|
|
|
|
/// The font switch legally carries an extension and parameters; the bare
|
|
/// form is only the common spelling.
|
|
#[test]
|
|
fn a_parameterised_font_switch_still_yields_its_symbol() {
|
|
assert_eq!(symbol_font_switch("\\Fgdt;j"), Some('j'));
|
|
assert_eq!(symbol_font_switch("\\Fgdt.shx|b0|i0|c134|p6;j"), Some('j'));
|
|
assert_eq!(symbol_font_switch("\\FGDT;j"), Some('j'));
|
|
// Not the symbol font — leave it to the generic format-code stripper.
|
|
assert_eq!(symbol_font_switch("\\Farial;j"), None);
|
|
assert_eq!(symbol_font_switch("\\C1"), None);
|
|
}
|
|
|
|
/// The frame must take its size from the dimension style. A DWG carries
|
|
/// only the style's handle and leaves the entity's own height at a small
|
|
/// non-zero default, so trusting that field drew the frame ~10x too small.
|
|
#[test]
|
|
fn the_frame_is_sized_from_the_dimension_style_not_the_entity_default() {
|
|
use codec::tables::DimStyle;
|
|
let mut doc = codec::CadDocument::new();
|
|
let handle = codec::Handle::from(0x27_u64);
|
|
let mut style = DimStyle::new("ISO-25");
|
|
style.handle = handle;
|
|
style.dimtxt = 2.5;
|
|
style.dimgap = 0.625;
|
|
style.dimscale = 1.0;
|
|
doc.dim_styles.add_or_replace(style);
|
|
|
|
let mut tol = Tolerance::new();
|
|
tol.text = "A".into();
|
|
// What a DWG gives us: the handle set, the metrics left at defaults.
|
|
tol.dimension_style_handle = Some(handle);
|
|
assert!(
|
|
tol.text_height < 1.0,
|
|
"guard: the default height must stay small for this test to mean anything"
|
|
);
|
|
|
|
let (boxes, _) = tessellate_tolerance(&tol, &doc);
|
|
// 2 x dimtxt. The entity's own default (0.18) would give 0.36.
|
|
let height = frame_height(&boxes);
|
|
assert!(
|
|
(height - 5.0).abs() < 1e-3,
|
|
"frame is {height} tall; 2 x the style's 2.5 character height is 5.0"
|
|
);
|
|
}
|
|
|
|
/// An entity that overrides a style variable on itself wins over the style.
|
|
/// The benchmark's tolerance keeps ISO-25 but overrides its text height to
|
|
/// 0.42; reading the style alone drew the frame several times too large.
|
|
#[test]
|
|
fn an_entity_override_beats_its_dimension_style() {
|
|
use codec::tables::DimStyle;
|
|
use codec::xdata::XDataValue;
|
|
|
|
let mut doc = codec::CadDocument::new();
|
|
let handle = codec::Handle::from(0x27_u64);
|
|
let mut style = DimStyle::new("ISO-25");
|
|
style.handle = handle;
|
|
style.dimtxt = 2.5;
|
|
style.dimgap = 0.625;
|
|
style.dimscale = 1.0;
|
|
doc.dim_styles.add_or_replace(style);
|
|
|
|
let mut tol = Tolerance::new();
|
|
tol.text = "A".into();
|
|
tol.dimension_style_handle = Some(handle);
|
|
|
|
// Without the override: 2 x the style's 2.5 character height.
|
|
let (boxes, _) = tessellate_tolerance(&tol, &doc);
|
|
assert!(
|
|
(frame_height(&boxes) - 5.0).abs() < 1e-3,
|
|
"guard: style-sized, got {}",
|
|
frame_height(&boxes)
|
|
);
|
|
|
|
// The entity's own overrides, shaped exactly as the file carries them:
|
|
// application "ACAD", naming itself "DSTYLE" in its first string value,
|
|
// then (variable code, value) pairs. Variable 140 = text height.
|
|
let mut rec = codec::xdata::ExtendedDataRecord::new("ACAD");
|
|
rec.add_value(XDataValue::String("DSTYLE".into()));
|
|
rec.add_value(XDataValue::ControlString("{".into()));
|
|
rec.add_value(XDataValue::Integer16(140));
|
|
rec.add_value(XDataValue::Real(0.42));
|
|
rec.add_value(XDataValue::ControlString("}".into()));
|
|
tol.common.extended_data.add_record(rec);
|
|
|
|
let (boxes, _) = tessellate_tolerance(&tol, &doc);
|
|
// 2 x 0.42 — the frame follows the entity's own character height.
|
|
assert!(
|
|
(frame_height(&boxes) - 0.84).abs() < 1e-3,
|
|
"override ignored: frame is {} tall, expected 0.84",
|
|
frame_height(&boxes)
|
|
);
|
|
}
|
|
|
|
fn frame_height(boxes: &[Vec<[f32; 2]>]) -> f32 {
|
|
let ys: Vec<f32> = boxes.iter().flatten().map(|p| p[1]).collect();
|
|
ys.iter().cloned().fold(f32::MIN, f32::max) - ys.iter().cloned().fold(f32::MAX, f32::min)
|
|
}
|
|
|
|
/// Rows read downward and start at the left edge.
|
|
#[test]
|
|
fn rows_stack_downward_and_left_align() {
|
|
let doc = codec::CadDocument::new();
|
|
let mut tol = Tolerance::new();
|
|
tol.text = BENCH.to_string();
|
|
let (boxes, _) = tessellate_tolerance(&tol, &doc);
|
|
|
|
// One closed rect per row (plus dividers); every rect starts at x = 0.
|
|
let rects: Vec<&Vec<[f32; 2]>> = boxes.iter().filter(|b| b.len() == 5).collect();
|
|
assert_eq!(rects.len(), 3, "one border per row");
|
|
for r in &rects {
|
|
let x0 = r.iter().map(|p| p[0]).fold(f32::MAX, f32::min);
|
|
assert!(x0.abs() < 1e-3, "row border not left-aligned: x0={x0}");
|
|
}
|
|
// The insertion point is the first row's centreline: row 0 straddles
|
|
// y = 0 and the frame only ever grows downward from it, so the anchor
|
|
// does not drift with the number of rows.
|
|
let h = tol.text_height as f32;
|
|
let top = rects
|
|
.iter()
|
|
.flat_map(|r| r.iter())
|
|
.map(|p| p[1])
|
|
.fold(f32::MIN, f32::max);
|
|
let bottom = rects
|
|
.iter()
|
|
.flat_map(|r| r.iter())
|
|
.map(|p| p[1])
|
|
.fold(f32::MAX, f32::min);
|
|
assert!(
|
|
(top - h).abs() < 1e-4,
|
|
"frame should reach +h ({h}) above the insertion point, reaches {top}"
|
|
);
|
|
// 3 rows of 2h, the first centred on 0 -> the last bottom is -5h.
|
|
assert!(
|
|
(bottom - (-5.0 * h)).abs() < 1e-4,
|
|
"three rows should reach -5h ({}) below, reach {bottom}",
|
|
-5.0 * h
|
|
);
|
|
|
|
// Row 0 (4 compartments) is the widest AND the topmost.
|
|
let top = |r: &Vec<[f32; 2]>| r.iter().map(|p| p[1]).fold(f32::MIN, f32::max);
|
|
let wide = |r: &Vec<[f32; 2]>| r.iter().map(|p| p[0]).fold(f32::MIN, f32::max);
|
|
assert!(
|
|
top(rects[0]) > top(rects[1]) && top(rects[1]) > top(rects[2]),
|
|
"rows must stack downward, row 0 on top"
|
|
);
|
|
assert!(
|
|
wide(rects[0]) > wide(rects[1]),
|
|
"the lone datum row must not inherit the four-compartment row's width"
|
|
);
|
|
}
|
|
|
|
/// The cell is measured with the font's normal letter spacing, so it must be
|
|
/// DRAWN with it too. They diverged once — the measurement assumed normal
|
|
/// spacing while the glyph run asked for none — and the characters ran
|
|
/// together inside correctly-sized boxes.
|
|
#[test]
|
|
fn cells_are_drawn_with_the_spacing_they_were_measured_with() {
|
|
let doc = codec::CadDocument::new();
|
|
let mut tol = Tolerance::new();
|
|
tol.text = "ABC".into();
|
|
|
|
let Some(RenderEntity {
|
|
object: RenderObject::Text(groups),
|
|
..
|
|
}) = tol.to_render(&doc)
|
|
else {
|
|
panic!("tolerance produced no text");
|
|
};
|
|
// The frame is the run-less group; every cell carries a run.
|
|
let runs: Vec<_> = groups.iter().filter_map(|g| g.run.as_ref()).collect();
|
|
assert!(!runs.is_empty(), "no cell runs emitted");
|
|
for r in &runs {
|
|
assert_eq!(
|
|
r.tracking, 1.0,
|
|
"cell text must use the font's normal letter spacing, like every \
|
|
other entity and like the width measurement"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The symbol font is reached by NAME, through the same lookup every other
|
|
/// font goes through — so a switch to it works wherever inline codes do, not
|
|
/// only inside a tolerance. An MTEXT carrying `{\\Fgdt;j}`, or a text style
|
|
/// literally named "gdt.shx", lands on it too, with no code of their own.
|
|
#[test]
|
|
fn the_symbol_font_is_reached_by_name_like_any_other() {
|
|
use crate::scene::text::font_face::Face;
|
|
for name in ["gdt", "GDT", "gdt.shx"] {
|
|
assert!(
|
|
crate::scene::text::lff::is_builtin(name),
|
|
"{name:?} must resolve to the bundled symbol font"
|
|
);
|
|
let face = Face::resolve(name);
|
|
let Face::Lff(font) = &face else {
|
|
panic!("{name:?} resolved to a system outline, not the stroke font");
|
|
};
|
|
assert_eq!(
|
|
font.name.to_ascii_lowercase(),
|
|
"gdt",
|
|
"{name:?} landed on {}",
|
|
font.name
|
|
);
|
|
// Every character the escape can carry draws from it.
|
|
for c in 'a'..='z' {
|
|
assert!(font.glyph(c).is_some(), "{name:?} has no glyph for {c:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// It holds symbols, not letters: the glyph for 'm' is the circled M, and
|
|
/// must not be the letter m the text fonts draw.
|
|
#[test]
|
|
fn a_letter_in_the_symbol_font_is_a_symbol() {
|
|
use crate::scene::text::font_face::Face;
|
|
let gdt = Face::resolve(SYMBOL_FONT);
|
|
let txt = Face::resolve(TEXT_FONT);
|
|
let mut differ = 0;
|
|
for c in 'a'..='z' {
|
|
let (g, x) = (gdt.glyph(c), txt.glyph(c));
|
|
if let (Some(g), Some(x)) = (g, x) {
|
|
if g.strokes.len() != x.strokes.len() || (g.advance - x.advance).abs() > 1e-3 {
|
|
differ += 1;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
differ >= 20,
|
|
"only {differ} of 26 differ from the text font — the symbol font is \
|
|
drawing letters"
|
|
);
|
|
}
|
|
|
|
/// A compartment is measured by what it draws, not by where the pen lands.
|
|
/// The pen stops one letter-space past the last glyph — that gap belongs to
|
|
/// whatever comes next, and counting it made every compartment that much
|
|
/// too wide.
|
|
#[test]
|
|
fn compartments_are_measured_by_their_ink_not_the_trailing_pen_gap() {
|
|
// Cap height, so glyph units and world units line up.
|
|
let h = 9.0_f32;
|
|
let document = codec::CadDocument::new();
|
|
let text_style = crate::entities::text_support::resolve_text_style("Standard", &document);
|
|
for src in ["{\\Fgdt;r}", "{\\Fgdt;n}tol{\\Fgdt;m}", "1{\\Fgdt;m}", "A"] {
|
|
let cell = parse_cell(src);
|
|
let pen: f32 = cell
|
|
.iter()
|
|
.map(|run| {
|
|
if run.font == SYMBOL_FONT {
|
|
run_advance(&run.text, SYMBOL_FONT, h, 1.0, 0.0)
|
|
} else {
|
|
run_advance(
|
|
&run.text,
|
|
&text_style.font_name,
|
|
h,
|
|
text_style.width_factor,
|
|
text_style.oblique_angle,
|
|
)
|
|
}
|
|
})
|
|
.sum();
|
|
let ink: f32 = cell
|
|
.iter()
|
|
.map(|r| {
|
|
crate::entities::text_support::text_local_bounds(r.font, &r.text, h, 1.0, 0.0)
|
|
.map(|b| b.ink_max[0] - b.ink_min[0])
|
|
.unwrap_or(0.0)
|
|
})
|
|
.sum();
|
|
let got = content_width(&cell, h, &text_style);
|
|
|
|
// Exactly one trailing gap comes off — the gaps BETWEEN runs are
|
|
// real spacing and must stay.
|
|
let last = cell.last().unwrap();
|
|
let (font, width) = if last.font == SYMBOL_FONT {
|
|
(SYMBOL_FONT, 1.0)
|
|
} else {
|
|
(text_style.font_name.as_str(), text_style.width_factor)
|
|
};
|
|
let spacing = letter_spacing(font, h, width);
|
|
assert!(
|
|
(got - (pen - spacing)).abs() < 1e-3,
|
|
"{src:?}: content {got} should be pen {pen} less one {spacing} gap"
|
|
);
|
|
let expected = ink + (cell.len() as f32 - 1.0) * spacing;
|
|
assert!(
|
|
(got - expected).abs() < 1e-3,
|
|
"{src:?}: content {got} should be ink {ink} plus {} inter-run gaps",
|
|
cell.len() - 1
|
|
);
|
|
}
|
|
}
|
|
}
|