fix(dimarc): complete merge integration

Preserve source-plane geometry and associative arc parameters while combining the latest properties and tolerance workflows.
This commit is contained in:
Hakan Seven committed 2026-08-27 01:00:23 +03:00
commit 6ef2d75e84
67 files changed
+4773 -883

No files matched your search

+4
View File
@@ -200,6 +200,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
oblique_angle,
is_backward: width_factor < 0.0,
is_upside_down: false,
is_vertical: false,
};
let layout = layout_mtext(&MTextRenderOpts {
// Not an MTEXT: text in a fixed box, never columnar.
@@ -213,6 +214,8 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
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,
});
@@ -304,6 +307,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
origin,
color: None,
fill_tris,
plane: None,
// Carry a GlyphRun so the attribute renders as SDF glyph quads
// (same as TEXT); the strokes above are the historical fallback and
// are suppressed by the per-group SDF path.
+156 -1
View File
@@ -2911,7 +2911,7 @@ use crate::scene::convert::tessellate::{
add_polyline, add_segment, append_arrow, arrow_from_block_with_deferred_hatch,
normalized_or, ArrowKind, DimGeom,
};
use crate::scene::model::wire_model::{SnapHint, WireModel};
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
fn apply_dimension_breaks(
document: &CadDocument,
@@ -3075,6 +3075,28 @@ fn tessellate_dimension_inner(
view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>,
) -> Vec<WireModel> {
if let Dimension::Arc(arc) = dim {
if let Some((local_arc, normal)) = arc_dimension_in_ocs(arc) {
let mut wires = tessellate_dimension_inner(
document,
handle,
&Dimension::Arc(local_arc),
selected,
entity_color,
line_weight_px,
anno_scale,
selected_set,
active_viewport,
bg_color,
None,
world_per_pixel,
);
for wire in &mut wires {
map_wire_ocs_to_wcs(wire, normal);
}
return wires;
}
}
let name = handle.value().to_string();
// (Baked-block fast path moved up into scene::tessellate_entity so the
// recursive call goes through the LOD ladder, not the kernel path.)
@@ -4728,6 +4750,139 @@ pub(crate) fn arc_dimension_angles(dimension: &DimensionArc) -> Option<(f32, f32
(sweep > 1.0e-12).then_some((start as f32, (start + sweep) as f32))
}
pub(crate) fn arc_dimension_in_ocs(
dimension: &DimensionArc,
) -> Option<(DimensionArc, Vector3)> {
let length = dimension.base.normal.length();
if !length.is_finite() || length <= 1.0e-12 {
return None;
}
let normal = dimension.base.normal / length;
if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 {
return None;
}
let normal_tuple = (normal.x, normal.y, normal.z);
let to_ocs = |point: Vector3| {
let point = crate::scene::view::transform::wcs_point_to_ocs(
(point.x, point.y, point.z),
normal_tuple,
);
Vector3::new(point.0, point.1, point.2)
};
let mut local = dimension.clone();
local.center_point = to_ocs(dimension.center_point);
let elevation = local.center_point.z;
let on_plane = |point: Vector3| {
let mut point = to_ocs(point);
point.z = elevation;
point
};
local.definition_point = on_plane(dimension.definition_point);
local.first_extension_point = on_plane(dimension.first_extension_point);
local.second_extension_point = on_plane(dimension.second_extension_point);
local.first_leader_point = on_plane(dimension.first_leader_point);
local.second_leader_point = on_plane(dimension.second_leader_point);
local.base.definition_point = local.definition_point;
local.base.text_middle_point = on_plane(dimension.base.text_middle_point);
local.base.insertion_point = on_plane(dimension.base.insertion_point);
local.base.normal = Vector3::UNIT_Z;
Some((local, normal))
}
fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) {
let normal_tuple = (normal.x, normal.y, normal.z);
let map = |x: f64, y: f64, z: f64| {
crate::scene::view::transform::ocs_point_to_wcs((x, y, z), normal_tuple)
};
let map_split = |high: &mut Vec<[f32; 3]>, low: &mut Vec<[f32; 3]>| {
let mut mapped_high = Vec::with_capacity(high.len());
let mut mapped_low = Vec::with_capacity(high.len());
for (index, point) in high.iter().enumerate() {
if point[0].is_nan() {
mapped_high.push(*point);
mapped_low.push([0.0; 3]);
continue;
}
let residual = low.get(index).copied().unwrap_or([0.0; 3]);
let point = map(
point[0] as f64 + residual[0] as f64,
point[1] as f64 + residual[1] as f64,
point[2] as f64 + residual[2] as f64,
);
let (xh, xl) = WireModel::split_ds(point.0);
let (yh, yl) = WireModel::split_ds(point.1);
let (zh, zl) = WireModel::split_ds(point.2);
mapped_high.push([xh, yh, zh]);
mapped_low.push([xl, yl, zl]);
}
*high = mapped_high;
*low = mapped_low;
};
map_split(&mut wire.points, &mut wire.points_low);
map_split(&mut wire.fill_tris, &mut wire.fill_tris_low);
map_split(&mut wire.pick_tris, &mut wire.pick_tris_low);
wire.text_verts = crate::scene::model::wire_model::map_text_verts(
&wire.text_verts,
map,
);
for (point, _) in &mut wire.snap_pts {
let mapped = map(point.x, point.y, point.z);
*point = DVec3::new(mapped.0, mapped.1, mapped.2);
}
for point in &mut wire.key_vertices {
let mapped = map(point[0], point[1], point[2]);
*point = [mapped.0, mapped.1, mapped.2];
}
if let Some(marker) = &mut wire.point_marker {
let origin = map(marker.origin.x, marker.origin.y, marker.origin.z);
let vector = |value: DVec3| {
let mapped = map(value.x, value.y, value.z);
DVec3::new(mapped.0, mapped.1, mapped.2)
};
marker.origin = DVec3::new(origin.0, origin.1, origin.2);
marker.normal = vector(marker.normal).normalize_or(DVec3::Z);
marker.axis_x = vector(marker.axis_x).normalize_or(DVec3::X);
marker.axis_y = vector(marker.axis_y).normalize_or(DVec3::Y);
}
for tangent in &mut wire.tangent_geoms {
match tangent {
TangentGeom::Line { p1, p2 } => {
let first = map(p1[0] as f64, p1[1] as f64, p1[2] as f64);
let second = map(p2[0] as f64, p2[1] as f64, p2[2] as f64);
*p1 = [first.0 as f32, first.1 as f32, first.2 as f32];
*p2 = [second.0 as f32, second.1 as f32, second.2 as f32];
}
TangentGeom::Circle { center, radius } => {
let center = map(
center[0] as f64,
center[1] as f64,
center[2] as f64,
);
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes(normal_tuple);
*tangent = TangentGeom::PlanarCircle {
center: [center.0, center.1, center.2],
axis_x: [x_axis.0, x_axis.1, x_axis.2],
axis_y: [y_axis.0, y_axis.1, y_axis.2],
radius: *radius as f64,
};
}
TangentGeom::PlanarCircle { center, axis_x, axis_y, .. }
| TangentGeom::Arc { center, axis_x, axis_y, .. } => {
let mapped = map(center[0], center[1], center[2]);
let map_vector = |axis: [f64; 3]| {
let mapped = map(axis[0], axis[1], axis[2]);
[mapped.0, mapped.1, mapped.2]
};
*center = [mapped.0, mapped.1, mapped.2];
*axis_x = map_vector(*axis_x);
*axis_y = map_vector(*axis_y);
}
}
}
wire.aabb = WireModel::UNBOUNDED_AABB;
}
fn angular_dimension_frame(dim: &Dimension) -> Option<(Vec3, f32, f32, f32)> {
let (vertex, start, end, arc_point) = match dim {
Dimension::Angular2Ln(value) => {
+128 -32
View File
@@ -5,7 +5,8 @@ use crate::entities::common::{
edit_angle_prop as edit_angle, edit_prop as edit, num_prop as num_row, ro_prop as ro, square_grip, triangle_grip,
};
use crate::entities::text_support::{
layout_mtext, resolve_text_style, GlyphBox, MTextColumns, MTextRenderOpts, MTextVAnchor,
clamp_mtext_column_count, layout_mtext, resolve_text_style, GlyphBox, MTextColumns,
MTextRenderOpts, MTextVAnchor,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
@@ -24,10 +25,19 @@ fn columns_of(t: &MText) -> MTextColumns {
if c.column_type == 0 {
return MTextColumns::default();
}
let count = clamp_mtext_column_count(c.column_count) as usize;
MTextColumns {
count: c.column_count.max(0) as usize,
count,
width: c.width as f32,
gutter: c.gutter as f32,
flow_reversed: c.flow_reversed,
auto_height: c.auto_height,
heights: c
.heights
.iter()
.take(count)
.map(|height| *height as f32)
.collect(),
}
}
@@ -120,7 +130,14 @@ pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec<GlyphBox>
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
exact_line_spacing: matches!(
t.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom)
|| matches!(t.drawing_direction, DrawingDirection::ByStyle)
&& resolved_style.is_vertical,
want_glyph_boxes: true,
columns: columns_of(t),
});
@@ -169,7 +186,14 @@ fn to_render(t: &MText, document: &acadrust::CadDocument) -> RenderEntity {
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
exact_line_spacing: matches!(
t.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom)
|| matches!(t.drawing_direction, DrawingDirection::ByStyle)
&& resolved_style.is_vertical,
want_glyph_boxes: false,
columns: columns_of(t),
});
@@ -231,8 +255,43 @@ fn grips(t: &MText) -> Vec<GripDef> {
t.insertion_point.z,
);
let (dir, k) = width_grip_axis(t);
let width_grip = p + dir * (k * t.rectangle_width.max(0.0));
vec![square_grip(0, p), triangle_grip(1, width_grip)]
let column_count = clamp_mtext_column_count(t.column_data.column_count);
let columns_active =
t.column_data.column_type != 0 && column_count > 1 && t.column_data.width > 0.0;
let block_width = if columns_active {
t.column_data.width * column_count as f64
+ t.column_data.gutter * (column_count - 1) as f64
} else {
t.rectangle_width.max(0.0)
};
let width_grip = p + dir * (k * block_width);
let mut result = vec![square_grip(0, p), triangle_grip(1, width_grip)];
let height = t
.rectangle_height
.or_else(|| t.column_data.heights.first().copied())
.unwrap_or(0.0);
if height > 0.0 && !(columns_active && t.column_data.auto_height) {
let (sin, cos) = t.rotation.sin_cos();
let down = glam::DVec3::new(sin, -cos, 0.0);
let (_, vertical) = attach_anchors(t);
let factor = match vertical {
MTextVAnchor::Top
| MTextVAnchor::MiddleOfTopLine
| MTextVAnchor::BottomOfTopLine => 1.0,
MTextVAnchor::Middle => 0.5,
MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0,
};
result.push(triangle_grip(2, p + down * (factor * height)));
}
if columns_active {
result.push(triangle_grip(3, p + dir * (k * t.column_data.width)));
result.push(triangle_grip(
4,
p + dir * (k * (t.column_data.width + t.column_data.gutter)),
));
}
result
}
fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str {
@@ -248,10 +307,9 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
// by the line-spacing factor.
let line_space_distance = t.height * 1.666_666_666_666_667 * t.line_spacing_factor;
let text_frame_on = (t.background_fill_flags & 0x10) != 0;
// Defined width is only live without columns; defined height is live for
// static columns or manual-height dynamic columns, grayed otherwise.
// Defined width is reported by Properties but is not edited there. Defined
// height remains live for static columns or manual-height dynamic columns.
let col_type = t.column_data.column_type;
let width_editable = col_type == 0;
let height_editable = col_type == 1 || (col_type == 2 && !t.column_data.auto_height);
vec![
PropSection {
@@ -353,7 +411,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
.collect(),
},
},
num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, width_editable),
num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, false),
num_row(
t!("Defined height").as_ref(),
"rect_h",
@@ -371,20 +429,6 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
.collect(),
},
},
// Count / width / gutter are live only when columns are on.
num_row(
t!("Column count").as_ref(),
"col_count",
t.column_data.column_count as f64,
col_type != 0,
),
num_row(t!("Column width").as_ref(), "col_width", t.column_data.width, col_type != 0),
num_row(
t!("Column gutter").as_ref(),
"col_gutter",
t.column_data.gutter,
col_type != 0,
),
Property {
label: t!("Text frame").into_owned(),
field: "text_frame",
@@ -502,21 +546,20 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) {
"ins_y" => t.insertion_point.y = v,
"ins_z" => t.insertion_point.z = v,
"height" if v > 0.0 => t.height = v,
"rect_w" if v > 0.0 => t.rectangle_width = v,
"rect_h" if v > 0.0 => t.rectangle_height = Some(v),
"rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v),
"rotation" => t.rotation = v.to_radians(),
"line_spacing" if v > 0.0 => t.line_spacing_factor = v,
"line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v,
// Editing the absolute distance back-solves the line-spacing factor so
// the two stay consistent (distance = height × 5/3 × factor).
"line_space_distance" if v > 0.0 => {
let denom = t.height * 1.666_666_666_666_667;
if denom > 0.0 {
t.line_spacing_factor = v / denom;
let factor = v / denom;
if (0.25..=4.0).contains(&factor) {
t.line_spacing_factor = factor;
}
}
}
"col_count" if v >= 1.0 => t.column_data.column_count = v.round() as i32,
"col_width" if v > 0.0 => t.column_data.width = v,
"col_gutter" if v >= 0.0 => t.column_data.gutter = v,
_ => {}
}
}
@@ -541,7 +584,60 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) {
let dx = p.x as f64 - t.insertion_point.x;
let dy = p.y as f64 - t.insertion_point.y;
let projected = dx * dir.x + dy * dir.y;
t.rectangle_width = (projected / k).max(0.01);
let width = (projected / k).max(0.0);
let column_count = clamp_mtext_column_count(t.column_data.column_count);
if t.column_data.column_type != 0 && column_count > 1 {
t.column_data.column_count = column_count;
let gaps = (column_count - 1) as f64;
t.column_data.width =
((width - t.column_data.gutter * gaps) / column_count as f64)
.max(0.01);
} else {
t.rectangle_width = width;
}
}
(2, GripApply::Absolute(p)) => {
let (sin, cos) = t.rotation.sin_cos();
let down = glam::DVec3::new(sin, -cos, 0.0);
let delta = glam::DVec3::new(
p.x as f64 - t.insertion_point.x,
p.y as f64 - t.insertion_point.y,
p.z as f64 - t.insertion_point.z,
);
let (_, vertical) = attach_anchors(t);
let factor = match vertical {
MTextVAnchor::Top
| MTextVAnchor::MiddleOfTopLine
| MTextVAnchor::BottomOfTopLine => 1.0,
MTextVAnchor::Middle => 0.5,
MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0,
};
let height = (delta.dot(down) / factor).max(0.01);
t.rectangle_height = Some(height);
if t.column_data.column_type != 0 && !t.column_data.auto_height {
let column_count = clamp_mtext_column_count(t.column_data.column_count);
t.column_data.column_count = column_count;
t.column_data.heights = vec![height; column_count as usize];
}
}
(3, GripApply::Absolute(p)) => {
let (dir, k) = width_grip_axis(t);
let delta = glam::DVec3::new(
p.x as f64 - t.insertion_point.x,
p.y as f64 - t.insertion_point.y,
0.0,
);
t.column_data.width = (delta.dot(dir) / k).max(0.01);
}
(4, GripApply::Absolute(p)) => {
let (dir, k) = width_grip_axis(t);
let delta = glam::DVec3::new(
p.x as f64 - t.insertion_point.x,
p.y as f64 - t.insertion_point.y,
0.0,
);
t.column_data.gutter =
(delta.dot(dir) / k - t.column_data.width).max(0.0);
}
_ => {}
}
+16
View File
@@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<Rende
},
),
line_spacing_factor: ctx.line_spacing_factor as f32,
exact_line_spacing: matches!(
ctx.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
}))
@@ -412,6 +417,7 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
};
let layout = layout_mtext(&MTextRenderOpts {
columns: Default::default(),
@@ -424,6 +430,11 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: ctx.line_spacing_factor as f32,
exact_line_spacing: matches!(
ctx.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: 0.0,
vertical_text: vertical,
want_glyph_boxes: false,
});
@@ -2039,6 +2050,11 @@ impl MultiLeaderTess for MultiLeader {
attach_h_anchor: h_anchor,
v_anchor,
line_spacing_factor: ctx.line_spacing_factor as f32,
exact_line_spacing: matches!(
ctx.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: 0.0,
// Vertical flow (top-to-bottom) is stored per-context.
vertical_text: matches!(
ctx.text_flow_direction,
+2
View File
@@ -180,6 +180,7 @@ impl RenderConvertible for RasterImage {
origin: ins,
color: None,
fill_tris: vec![],
plane: None,
run: None,
}];
@@ -218,6 +219,7 @@ impl RenderConvertible for RasterImage {
origin,
color: None,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: path.to_string(),
font,
+6
View File
@@ -1187,6 +1187,7 @@ impl RenderConvertible for Table {
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),
}
};
@@ -1287,6 +1288,8 @@ impl RenderConvertible for Table {
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,
});
@@ -1453,6 +1456,7 @@ pub fn tessellate_table(
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),
}
};
@@ -1920,6 +1924,8 @@ pub fn tessellate_table(
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,
});
+182 -55
View File
@@ -43,7 +43,24 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) {
(HA::Left, VA::Baseline)
);
if needs_alignment_point {
if t.alignment_point.is_none() {
if matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) {
let point = t.alignment_point.unwrap_or(t.insertion_point);
let dx = point.x - t.insertion_point.x;
let dy = point.y - t.insertion_point.y;
if dx.hypot(dy) <= 1.0e-9 {
let span = t.height.max(1.0e-6)
* t.width_factor.abs().max(0.01)
* t.value.chars().count().max(1) as f64
* 0.6;
t.alignment_point = Some(acadrust::types::Vector3::new(
t.insertion_point.x + t.rotation.cos() * span,
t.insertion_point.y + t.rotation.sin() * span,
t.insertion_point.z,
));
} else {
t.alignment_point = Some(point);
}
} else if t.alignment_point.is_none() {
t.alignment_point = Some(t.insertion_point);
}
} else {
@@ -51,6 +68,21 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) {
}
}
fn two_point_span(t: &Text) -> Option<(f64, f64)> {
if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) {
return None;
}
let point = t.alignment_point?;
let dx = point.x - t.insertion_point.x;
let dy = point.y - t.insertion_point.y;
let distance = dx.hypot(dy);
(distance > 1.0e-9).then(|| (distance, dy.atan2(dx)))
}
fn displayed_rotation(t: &Text) -> f64 {
two_point_span(t).map_or(t.rotation, |(_, angle)| angle)
}
/// Resolved placement of a TEXT run: the baseline-anchored run origin (WCS xy)
/// plus every parameter needed to lay the glyphs out. Shared by `to_render` (the
/// stroke path) and the SDF-quad text collector so both place text identically.
@@ -98,8 +130,12 @@ pub(crate) fn acad_text_encode(value: &str) -> String {
out
}
fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity {
let p = text_run_placement(t, document);
pub(crate) fn to_render_at_scale(
t: &Text,
document: &acadrust::CadDocument,
annotation_scale: f32,
) -> RenderEntity {
let p = text_run_placement_at_scale(t, document, annotation_scale);
let snap_pt = glam::DVec3::new(p.wcs_insertion[0], p.wcs_insertion[1], p.wcs_insertion[2]);
// Parse `%%` codes via acadrust, re-encoded for the stroke tessellator.
let value = acad_text_encode(&p.value);
@@ -120,6 +156,7 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity {
origin: p.origin,
color: None,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: value,
font: p.font.clone(),
@@ -140,7 +177,16 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity {
/// Compute a TEXT entity's run placement (origin + layout params). Extracted
/// from `to_render` verbatim so the stroke and SDF-quad paths agree exactly.
pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPlacement {
pub fn text_run_placement_at_scale(
t: &Text,
document: &acadrust::CadDocument,
annotation_scale: f32,
) -> TextPlacement {
let annotation_scale = if annotation_scale.is_finite() && annotation_scale > 1.0e-9 {
annotation_scale
} else {
1.0
};
let normal = (t.normal.x, t.normal.y, t.normal.z);
let (wsx, wsy, wsz) = crate::scene::view::transform::ocs_point_to_wcs(
(
@@ -152,10 +198,9 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
);
let resolved_style = resolve_text_style(&t.style, document);
let font_name = resolved_style.font_name;
// AutoCAD text geometry rule: the entity stores the FINAL width factor /
// oblique angle, copied from the style at creation and persisting through
// style edits. Use it as-is. Only fall back to the style when the entity
// value is missing (the parser reports 0.0 for default-omitted fields).
// The entity stores the final width factor and oblique angle copied from
// its style at creation. Only fall back to the style when an omitted field
// was read as zero.
let base_wf = if t.width_factor.abs() > 1e-9 {
(t.width_factor as f32).clamp(0.01, 100.0)
} else {
@@ -168,17 +213,44 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
// mirror, and XOR keeps a double mirror an involution.
let eff_backward = resolved_style.is_backward ^ (t.generation_flags & 0x2 != 0);
let eff_upside = resolved_style.is_upside_down ^ (t.generation_flags & 0x4 != 0);
let width_factor = if eff_backward { -base_wf } else { base_wf };
let rotation = if eff_upside {
t.rotation as f32 + std::f32::consts::PI
} else {
t.rotation as f32
};
let mut width_factor = if eff_backward { -base_wf } else { base_wf };
let oblique_angle = if t.oblique_angle.abs() > 1e-9 {
t.oblique_angle as f32
} else {
resolved_style.oblique_angle
};
let value_for_bounds = resolve_dxf_special_chars(&t.value);
let mut height = t.height.max(1.0e-9) as f32;
let mut base_rotation = t.rotation as f32;
// Aligned and Fit are true two-point modes. Both derive their baseline
// direction from the endpoints. Aligned scales height uniformly; Fit keeps
// the height and changes only the horizontal factor.
if let Some((span, angle)) = two_point_span(t) {
base_rotation = angle as f32;
if let Some(base_bounds) = text_local_bounds(
&font_name,
&value_for_bounds,
height,
width_factor,
oblique_angle,
) {
if base_bounds.advance > 1.0e-6 {
let scale =
(span as f32 / annotation_scale / base_bounds.advance).max(1.0e-6);
if matches!(t.horizontal_alignment, HA::Aligned) {
height *= scale;
} else {
width_factor *= scale;
}
}
}
}
let rotation = if eff_upside {
base_rotation + std::f32::consts::PI
} else {
base_rotation
};
// Anchor stays f64: large coordinates (UTM etc.) lose ~0.5 units of
// precision when cast to f32, which snaps text baselines onto a coarse
// grid and makes adjacent rows collide. Only the small local offsets
@@ -188,17 +260,16 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
&t.vertical_alignment,
&t.alignment_point,
) {
(HA::Aligned | HA::Middle | HA::Fit, _, Some(a)) => [a.x, a.y],
(HA::Aligned | HA::Fit, _, _) => [t.insertion_point.x, t.insertion_point.y],
(HA::Middle, _, Some(a)) => [a.x, a.y],
(HA::Center | HA::Right, _, Some(a)) => [a.x, a.y],
(_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x, a.y],
_ => [t.insertion_point.x, t.insertion_point.y],
};
// Strip %%u/%%o for bounds (they add no width); resolve %%d/%%c/%%p for correct advance.
let value_for_bounds = resolve_dxf_special_chars(&t.value);
let bounds = text_local_bounds(
&font_name,
&value_for_bounds,
t.height as f32,
height,
width_factor,
oblique_angle,
);
@@ -213,7 +284,8 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
let ax = match t.horizontal_alignment {
HA::Left => 0.0,
HA::Center | HA::Middle => b.advance * 0.5 * sign,
HA::Right | HA::Aligned | HA::Fit => b.advance * sign,
HA::Right => b.advance * sign,
HA::Aligned | HA::Fit => 0.0,
};
// Vertical anchor uses the inked extent (cap / baseline geometry).
let ay = match t.vertical_alignment {
@@ -236,7 +308,7 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
];
TextPlacement {
origin,
height: t.height as f32,
height,
rotation,
width_factor,
oblique_angle,
@@ -247,12 +319,19 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
}
fn grips(t: &Text) -> Vec<GripDef> {
let p = glam::DVec3::new(
let insertion = glam::DVec3::new(
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
);
vec![square_grip(0, p)]
let mut grips = vec![square_grip(0, insertion)];
if let Some(point) = t.alignment_point {
grips.push(square_grip(
1,
glam::DVec3::new(point.x, point.y, point.z),
));
}
grips
}
fn properties(t: &Text, text_style_names: &[String]) -> Vec<PropSection> {
@@ -263,15 +342,25 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec<PropSection> {
// both points are live.
let is_plain_left = matches!(t.horizontal_alignment, HA::Left)
&& matches!(t.vertical_alignment, VA::Baseline);
let pos_editable = is_plain_left || matches!(t.horizontal_alignment, HA::Aligned | HA::Fit);
let align_editable = !is_plain_left;
// The alignment point is meaningless (reset to the origin) for plain-Left text.
let is_aligned = matches!(t.horizontal_alignment, HA::Aligned);
let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit);
// The visible Properties palette exposes Text alignment as calculated
// coordinates and Position as the editable placement point. For aligned
// and fit text Position targets the first endpoint; for every other
// non-left mode it targets the alignment anchor that actually places the
// glyph run.
let position_uses_alignment = !is_plain_left && !is_two_point;
let ap = t.alignment_point.unwrap_or(t.insertion_point);
let (ax, ay, az) = if align_editable {
let (ax, ay, az) = if !is_plain_left {
(ap.x, ap.y, ap.z)
} else {
(0.0, 0.0, 0.0)
};
let position = if position_uses_alignment {
ap
} else {
t.insertion_point
};
vec![
PropSection {
title: t!("Text").into_owned(),
@@ -331,22 +420,31 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec<PropSection> {
t!("Height").as_ref(),
"height",
t.height,
crate::entities::common::style_fixed_height(&t.style).is_none(),
!is_aligned
&& crate::entities::common::style_fixed_height(&t.style).is_none(),
),
edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()),
if is_two_point {
ro(
t!("Rotation").as_ref(),
"rotation",
crate::entities::common::format_angle(displayed_rotation(t)),
)
} else {
edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees())
},
edit(t!("Width factor").as_ref(), "width_factor", t.width_factor),
edit_angle(t!("Obliquing").as_ref(), "oblique_angle", t.oblique_angle.to_degrees()),
num_row(t!("Text alignment X").as_ref(), "align_x", ax, align_editable),
num_row(t!("Text alignment Y").as_ref(), "align_y", ay, align_editable),
num_row(t!("Text alignment Z").as_ref(), "align_z", az, align_editable),
num_row(t!("Text alignment X").as_ref(), "align_x", ax, false),
num_row(t!("Text alignment Y").as_ref(), "align_y", ay, false),
num_row(t!("Text alignment Z").as_ref(), "align_z", az, false),
],
},
PropSection {
title: t!("Geometry").into_owned(),
props: vec![
num_row(t!("Position X").as_ref(), "ins_x", t.insertion_point.x, pos_editable),
num_row(t!("Position Y").as_ref(), "ins_y", t.insertion_point.y, pos_editable),
num_row(t!("Position Z").as_ref(), "ins_z", t.insertion_point.z, pos_editable),
num_row(t!("Position X").as_ref(), "ins_x", position.x, true),
num_row(t!("Position Y").as_ref(), "ins_y", position.y, true),
num_row(t!("Position Z").as_ref(), "ins_z", position.z, true),
],
},
PropSection {
@@ -439,37 +537,64 @@ fn apply_geom_prop(t: &mut Text, field: &str, value: &str) {
return;
};
match field {
"ins_x" => t.insertion_point.x = v,
"ins_y" => t.insertion_point.y = v,
"ins_z" => t.insertion_point.z = v,
"align_x" | "align_y" | "align_z" => {
let ins = t.insertion_point;
let ap = t.alignment_point.get_or_insert(ins);
"ins_x" | "ins_y" | "ins_z" => {
let plain_left = matches!(t.horizontal_alignment, HA::Left)
&& matches!(t.vertical_alignment, VA::Baseline);
let two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit);
let target = if !plain_left && !two_point {
let insertion = t.insertion_point;
t.alignment_point.get_or_insert(insertion)
} else {
&mut t.insertion_point
};
match field {
"align_x" => ap.x = v,
"align_y" => ap.y = v,
_ => ap.z = v,
"ins_x" => target.x = v,
"ins_y" => target.y = v,
_ => target.z = v,
}
}
"height" if v > 0.0 => t.height = v,
"rotation" => t.rotation = v.to_radians(),
"align_x" | "align_y" | "align_z" => {
// Calculated display rows are intentionally not writable.
return;
}
"height"
if v > 0.0 && !matches!(t.horizontal_alignment, HA::Aligned) =>
{
t.height = v
}
"rotation" if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) => {
t.rotation = v.to_radians()
}
"width_factor" if v > 0.0 => t.width_factor = v,
"oblique_angle" => t.oblique_angle = v.to_radians(),
"oblique_angle" if (-85.0..=85.0).contains(&v) => {
t.oblique_angle = v.to_radians()
}
_ => {}
}
}
fn apply_grip(t: &mut Text, _grip_id: usize, apply: GripApply) {
fn apply_grip(t: &mut Text, grip_id: usize, apply: GripApply) {
match apply {
GripApply::Absolute(p) => {
t.insertion_point.x = p.x as f64;
t.insertion_point.y = p.y as f64;
t.insertion_point.z = p.z as f64;
let target = if grip_id == 1 {
let insertion = t.insertion_point;
t.alignment_point.get_or_insert(insertion)
} else {
&mut t.insertion_point
};
target.x = p.x;
target.y = p.y;
target.z = p.z;
}
GripApply::Translate(d) => {
t.insertion_point.x += d.x as f64;
t.insertion_point.y += d.y as f64;
t.insertion_point.z += d.z as f64;
t.insertion_point.x += d.x;
t.insertion_point.y += d.y;
t.insertion_point.z += d.z;
if let Some(point) = t.alignment_point.as_mut() {
point.x += d.x;
point.y += d.y;
point.z += d.z;
}
}
}
}
@@ -495,7 +620,7 @@ fn apply_transform(t: &mut Text, tr: &EntityTransform) {
impl RenderConvertible for Text {
fn to_render(&self, document: &acadrust::CadDocument) -> Option<RenderEntity> {
Some(to_render(self, document))
Some(to_render_at_scale(self, document, 1.0))
}
}
@@ -551,7 +676,9 @@ impl Grippable for Text {
value: f64,
) {
use crate::scene::model::object::GripMenuAction as A;
if matches!(action, A::RotateText) {
if matches!(action, A::RotateText)
&& !matches!(self.horizontal_alignment, HA::Aligned | HA::Fit)
{
self.rotation = value.to_radians();
}
}
+177 -8
View File
@@ -11,6 +11,7 @@ pub struct ResolvedTextStyle {
pub oblique_angle: f32,
pub is_backward: bool,
pub is_upside_down: bool,
pub is_vertical: bool,
}
pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle {
@@ -76,6 +77,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
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),
}
}
@@ -1012,6 +1014,11 @@ pub struct MTextRenderOpts<'a> {
pub v_anchor: MTextVAnchor,
/// DXF code 44 — multiplier on the default 5/3-em baseline gap.
pub line_spacing_factor: f32,
/// `true` fixes every baseline advance to the entity spacing. `false`
/// treats it as a minimum and expands for taller inline runs.
pub exact_line_spacing: bool,
/// User-defined text-box/column height. Zero means content-driven.
pub rectangle_height: f32,
/// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2).
pub vertical_text: bool,
/// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with
@@ -1025,10 +1032,15 @@ pub struct MTextRenderOpts<'a> {
/// An MTEXT's column layout, flattened from its `column_data`.
///
/// Content moves to the next column at a `\N` break. Filling a column and
/// spilling into the next on its own is not modelled: `heights` / `auto_height`
/// are not read, so a column runs as long as its content does.
#[derive(Clone, Copy, Debug, Default)]
/// Content moves to the next column at an explicit `\N` break or when the
/// configured manual/automatic column height is exhausted.
pub(crate) const MAX_MTEXT_COLUMNS: i32 = 256;
pub(crate) fn clamp_mtext_column_count(count: i32) -> i32 {
count.clamp(1, MAX_MTEXT_COLUMNS)
}
#[derive(Clone, Debug, Default)]
pub struct MTextColumns {
/// Column count. 0 or 1 both mean "no column layout".
pub count: usize,
@@ -1036,6 +1048,12 @@ pub struct MTextColumns {
pub width: f32,
/// Gap between two adjacent columns, world units.
pub gutter: f32,
/// Whether logical column flow starts at the opposite side.
pub flow_reversed: bool,
/// Dynamic-column height is balanced from content when true.
pub auto_height: bool,
/// Optional manual height for each logical column.
pub heights: Vec<f32>,
}
impl MTextColumns {
@@ -1046,7 +1064,17 @@ impl MTextColumns {
/// Left edge of column `i`, relative to the text block's own left edge.
pub fn offset_of(&self, i: usize) -> f32 {
i as f32 * (self.width + self.gutter)
let physical = if self.flow_reversed {
self.count.saturating_sub(1).saturating_sub(i)
} else {
i
};
physical as f32 * (self.width + self.gutter)
}
fn total_width(&self) -> f32 {
self.width * self.count as f32
+ self.gutter.max(0.0) * self.count.saturating_sub(1) as f32
}
}
@@ -1118,6 +1146,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
indent_right: f32,
tab_stops: Vec<TabStop>,
is_first_in_paragraph: bool,
starts_column: bool,
/// Which column this line lives in; always 0 without a column layout.
column: usize,
/// Extra gap above (paragraph's first wrapped line only) / below (last
@@ -1128,7 +1157,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
line_spacing: Option<ParaLineSpacing>,
}
let cols = opts.columns;
let cols = opts.columns.clone();
// A `\N` past the last column has nowhere to go — keep those lines in the
// last one rather than laying them out beyond the block.
let last_col = cols.count.saturating_sub(1);
@@ -1255,6 +1284,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
indent_right: para.indent_right,
tab_stops: para.tab_stops.clone(),
is_first_in_paragraph: idx == 0,
starts_column: idx == 0 && para.starts_column,
column,
// The gap above rides the first wrapped line; the gap below the
// last, so an interior wrap keeps normal single spacing.
@@ -1277,6 +1307,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
indent_right: 0.0,
tab_stops: Vec::new(),
is_first_in_paragraph: true,
starts_column: false,
column: 0,
space_before: 0.0,
space_after: 0.0,
@@ -1366,15 +1397,86 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
};
// A paragraph's own `\psm#`/`\pse#` overrides the entity factor for
// its lines; `Exact` fixes the baseline gap outright.
let entity_gap = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
match s.line_spacing {
Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e,
Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => {
mh * m * (5.0 / 3.0) * base_font.line_spacing()
}
_ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(),
_ if opts.exact_line_spacing => entity_gap,
_ => (mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing())
.max(entity_gap),
}
})
.collect();
// Flow lines into the next column when the active column's configured
// height is exhausted. Explicit `\N` breaks remain authoritative. Dynamic
// auto-height balances the content; static/manual columns use their stored
// height (falling back to the entity rectangle height).
if cols.active() {
let mut pending_after = 0.0_f32;
let total_advance: f32 = sub_lines
.iter()
.enumerate()
.map(|(index, line)| {
if line.starts_column {
pending_after = 0.0;
}
let spacing = if line.is_first_in_paragraph {
line.space_before + pending_after
} else {
0.0
};
pending_after = line.space_after;
per_line_h.get(index).copied().unwrap_or(entity_h) + spacing
})
.sum();
let balanced = (total_advance / cols.count.max(1) as f32).max(entity_h);
let mut current_column = 0usize;
let mut used = 0.0_f32;
let mut pending_after = 0.0_f32;
for (index, line) in sub_lines.iter_mut().enumerate() {
if line.starts_column {
current_column = (current_column + 1).min(cols.count - 1);
used = 0.0;
pending_after = 0.0;
}
let limit = if cols.auto_height {
balanced
} else {
cols.heights
.get(current_column)
.copied()
.filter(|height| *height > 0.0)
.unwrap_or(opts.rectangle_height)
};
let line_advance = per_line_h.get(index).copied().unwrap_or(entity_h);
let spacing = if line.is_first_in_paragraph {
line.space_before + pending_after
} else {
0.0
};
let mut advance = line_advance + spacing;
if limit > 0.0
&& used > 0.0
&& used + advance > limit
&& current_column + 1 < cols.count
{
current_column += 1;
used = 0.0;
advance = line_advance
+ if line.is_first_in_paragraph {
line.space_before
} else {
0.0
};
}
line.column = current_column;
used += advance;
pending_after = line.space_after;
}
}
// Per-line text height — the tallest Word on the line, an empty line
// inheriting the previous line's height (same rule as `per_line_h`). Unlike
// `line_max_h` it has no `entity_h` floor, so it reflects the ACTUAL text
@@ -1442,7 +1544,12 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
MTextVAnchor::BottomOfTopLine => 0.0,
};
let attach_h_anchor = opts.attach_h_anchor;
let box_left = -attach_h_anchor * rect_w;
let block_width = if cols.active() {
cols.total_width()
} else {
rect_w
};
let box_left = -attach_h_anchor * block_width;
let rot = opts.rotation;
let (cos_r, sin_r) = (rot.cos(), rot.sin());
let ins_x = opts.insertion[0];
@@ -1734,6 +1841,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: s,
font: font_name.to_string(),
@@ -1806,6 +1914,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: text.clone(),
font: font_name.to_string(),
@@ -1840,6 +1949,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris: vec![],
plane: None,
run: None,
});
}
@@ -1938,6 +2048,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: text.to_string(),
font: font_name.to_string(),
@@ -1953,6 +2064,49 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
emit(denominator, valign_dy);
emit(numerator, valign_dy + run_h * STACK_RAISE);
if opts.want_glyph_boxes {
let slots = numerator.chars().count()
+ denominator.chars().count()
+ usize::from(!denominator.is_empty());
if slots > 0 {
let top = valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE);
for index in 0..slots {
let x0 = cursor_x + slot_w * index as f32 / slots as f32;
let x1 = cursor_x + slot_w * (index + 1) as f32 / slots as f32;
let corners = [
to_world(line_base_x, line_base_y, x0, valign_dy),
to_world(line_base_x, line_base_y, x1, valign_dy),
to_world(line_base_x, line_base_y, x0, top),
to_world(line_base_x, line_base_y, x1, top),
];
let xmin = corners
.iter()
.map(|p| p.0)
.fold(f32::INFINITY, f32::min);
let xmax = corners
.iter()
.map(|p| p.0)
.fold(f32::NEG_INFINITY, f32::max);
let ymin = corners
.iter()
.map(|p| p.1)
.fold(f32::INFINITY, f32::min);
let ymax = corners
.iter()
.map(|p| p.1)
.fold(f32::NEG_INFINITY, f32::max);
glyph_boxes.push(GlyphBox {
vis,
xmin,
xmax,
ymin,
ymax,
});
vis += 1;
}
}
}
if *bar && slot_w > 0.0 {
// The rule is plain geometry, not a glyph, so it rides a
// run-less group — those keep their strokes, while a
@@ -1972,9 +2126,15 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin: [ox, oy],
color,
fill_tris: Vec::new(),
plane: None,
run: None,
});
}
local_bounds[0] = local_bounds[0].min(cursor_x);
local_bounds[1] = local_bounds[1].min(line_ly + valign_dy);
local_bounds[2] = local_bounds[2].max(cursor_x + slot_w);
local_bounds[3] = local_bounds[3]
.max(line_ly + valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE));
cursor_x += slot_w;
}
AtomKind::Space => {
@@ -2041,6 +2201,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris: vec![],
plane: None,
run: None,
});
}
@@ -2169,6 +2330,7 @@ mod tests {
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
}
}
@@ -2199,6 +2361,8 @@ mod tests {
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
});
@@ -2222,6 +2386,8 @@ mod tests {
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
});
@@ -2378,6 +2544,7 @@ mod v_anchor_tests {
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
};
layout_mtext(&MTextRenderOpts {
columns: Default::default(),
@@ -2390,6 +2557,8 @@ mod v_anchor_tests {
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: true,
})
+232 -124
View File
@@ -3,8 +3,10 @@ use acadrust::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, TextStroke, RenderEntity, RenderObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
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;
@@ -131,7 +133,7 @@ fn parse_cell(s: &str) -> Cell {
/// (`\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.
fn symbol_font_switch(inner: &str) -> Option<char> {
pub(crate) fn symbol_font_switch(inner: &str) -> Option<char> {
let rest = inner
.strip_prefix("\\F")
.or_else(|| inner.strip_prefix("\\f"))?;
@@ -144,51 +146,6 @@ fn symbol_font_switch(inner: &str) -> Option<char> {
tail.chars().next()
}
/// Per-entity overrides of individual dimension-style variables, carried as
/// extended data under the `DSTYLE` application.
///
/// An entity may keep its style yet override single variables on itself, as
/// `(variable group code, value)` pairs. Reading the style but ignoring these
/// draws the frame at the style's size rather than its own — which is how this
/// one, overriding its text height to a fraction of the style's, came out far
/// too large.
///
/// Only the character height is read — it is the frame's single geometric
/// input (see `tessellate_tolerance`); every other variable is left to the style.
fn dstyle_overrides(tol: &Tolerance) -> Option<f64> {
// The only variable the frame is built from.
const DIMTXT: i16 = 140;
use acadrust::xdata::XDataValue as V;
// The record belongs to the shared "ACAD" application and names itself in
// its FIRST STRING VALUE. There is no record called "DSTYLE" — asking for
// one finds nothing and leaves every override silently unread.
let rec = tol.common.extended_data.get_record("ACAD")?;
let mut vals = rec.values.iter();
match vals.next() {
Some(V::String(s)) if s == "DSTYLE" => {}
_ => return None,
}
let mut txt = None;
let mut pending: Option<i16> = None;
for v in vals {
match v {
V::Integer16(code) => pending = Some(*code),
V::Real(value) | V::Distance(value) => {
if pending.take() == Some(DIMTXT) {
txt = Some(*value);
}
}
// Braces and anything else just delimit; a non-numeric value also
// ends the pair we were waiting on.
_ => pending = None,
}
}
txt
}
/// The tolerance's dimension style — by handle first, then by name.
///
/// The order matters and is not interchangeable: a DWG records the style's
@@ -196,7 +153,7 @@ fn dstyle_overrides(tol: &Tolerance) -> Option<f64> {
/// 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.
fn resolve_dim_style<'a>(
pub(crate) fn resolve_dim_style<'a>(
tol: &Tolerance,
doc: &'a acadrust::CadDocument,
) -> Option<&'a acadrust::tables::DimStyle> {
@@ -222,8 +179,20 @@ fn resolve_dim_style<'a>(
///
/// 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) -> f32 {
crate::entities::text_support::text_local_bounds(font, text, height, 1.0, 0.0)
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)
}
@@ -232,8 +201,11 @@ fn run_advance(text: &str, font: &str, height: f32) -> f32 {
///
/// 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) -> f32 {
crate::scene::text::font_face::Face::resolve(font).letter_spacing() * height / 9.0
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.
@@ -243,36 +215,56 @@ fn letter_spacing(font: &str, height: f32) -> f32 {
/// 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) -> f32 {
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(|r| run_advance(&r.text, r.font, height))
.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();
(pen - letter_spacing(last.font, height)).max(0.0)
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 (already rotated); `strokes` are the glyph polylines rotated about the
/// origin (no origin translation — the wire-builder adds the origin).
/// point; `strokes` remain in the frame's local plane.
struct TolCell {
text: String,
font: &'static str,
font: String,
origin: [f32; 2],
strokes: Vec<Vec<[f32; 2]>>,
height: f32,
rotation: 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 as
/// 2-D polylines (rotated; run-less, so they always render as strokes)
/// - `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(
@@ -296,17 +288,29 @@ fn tessellate_tolerance(
// 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 scale = style
.map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 })
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 = dstyle_overrides(tol)
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.
@@ -329,19 +333,14 @@ fn tessellate_tolerance(
// (`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) + 2.0 * pad).max(min_cell_w) };
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();
// ── Transform helpers (local space — translation applied in tessellate.rs) ──
let angle = (tol.direction.y as f32).atan2(tol.direction.x as f32);
let (sa, ca) = angle.sin_cos();
// Rotate only; origin is kept as f64 and applied later with full precision.
let rot = |x: f32, y: f32| -> [f32; 2] { [x * ca - y * sa, x * sa + y * ca] };
let mut box_out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cells: Vec<TolCell> = Vec::new();
@@ -364,11 +363,11 @@ fn tessellate_tolerance(
let y0 = row_bottom(ri);
let y1 = y0 + cell_h;
box_out.push(vec![
rot(0.0, y0),
rot(rw, y0),
rot(rw, y1),
rot(0.0, y1),
rot(0.0, y0),
[0.0, y0],
[rw, y0],
[rw, y1],
[0.0, y1],
[0.0, y0],
]);
}
@@ -379,15 +378,13 @@ fn tessellate_tolerance(
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![rot(x_cursor, y0), rot(x_cursor, y1)]);
box_out.push(vec![[x_cursor, y0], [x_cursor, y1]]);
}
}
// ── Text content per cell ─────────────────────────────────────────────
// Each cell becomes a TolCell: its origin is the (rotated) cell position
// relative to the insertion point; its `strokes` are the glyph polylines
// rotated about that origin (used only when SDF is off). The GlyphRun the
// caller attaches lets the cell render as SDF text.
// 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.
@@ -399,26 +396,41 @@ fn tessellate_tolerance(
// 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)) * 0.5;
let mut run_x = cell_x + (cw - content_width(cell, h, &text_style)) * 0.5;
for run in cell {
let (text, font) = (run.text.clone(), run.font);
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, 1.0, 0.0, font, &text);
// Glyph polylines rotated about the run's origin (no origin
// translation — the wire-builder adds `origin`).
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()
.map(|pl| pl.into_iter().map(|[px, py]| rot(px, py)).collect())
.filter(|pl: &Vec<[f32; 2]>| !pl.is_empty())
.collect();
let advance = run_advance(&text, font, h);
let advance = run_advance(&text, &font, h, width_factor, oblique);
cells.push(TolCell {
text,
font,
origin: rot(run_x, row_y),
origin: [run_x, row_y],
strokes,
height: h,
rotation: angle,
width_factor,
oblique,
});
run_x += advance;
}
@@ -430,6 +442,24 @@ fn tessellate_tolerance(
(box_out, cells)
}
pub(crate) fn preview_strokes(
tol: &Tolerance,
doc: &acadrust::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 {
@@ -446,7 +476,57 @@ impl RenderConvertible for Tolerance {
// Build the feature-control frame in local space; origin stored as f64.
let (box_strokes, cells) = tessellate_tolerance(self, document);
let ins = [self.insertion_point.x, self.insertion_point.y];
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(
&acadrust::types::Color::from_index(value),
);
[red, green, blue]
})
};
let color_value = |color: &acadrust::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
@@ -456,9 +536,10 @@ impl RenderConvertible for Tolerance {
if !box_strokes.is_empty() {
groups.push(TextStroke {
strokes: box_strokes,
origin: ins,
color: None,
origin: [ins.x, ins.y],
color: frame_color,
fill_tris: vec![],
plane: Some(plane(ins)),
run: None,
});
}
@@ -466,18 +547,23 @@ impl RenderConvertible for Tolerance {
groups.push(TextStroke {
strokes: cell.strokes,
origin: [
ins[0] + cell.origin[0] as f64,
ins[1] + cell.origin[1] as f64,
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: None,
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.to_string(),
font: cell.font,
height: cell.height,
rotation: cell.rotation,
width_factor: 1.0,
oblique: 0.0,
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
@@ -539,12 +625,19 @@ impl Grippable for Tolerance {
// ── PropertyEditable ──────────────────────────────────────────────────────────
impl PropertyEditable for Tolerance {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
fn geometry_properties(&self, text_style_names: &[String]) -> Vec<PropSection> {
vec![
PropSection {
title: t!("Text").into_owned(),
props: vec![
ro(t!("Text style").as_ref(), "tol_text_style", String::new()),
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),
],
},
@@ -558,20 +651,15 @@ impl PropertyEditable for Tolerance {
},
PropSection {
title: t!("Misc").into_owned(),
props: vec![
ro(
t!("Dimension style").as_ref(),
"tol_dim_style",
if self.dimension_style_name.is_empty() {
"(default)".to_string()
} else {
self.dimension_style_name.clone()
},
),
edit(t!("Direction X").as_ref(), "tol_dir_x", self.direction.x),
edit(t!("Direction Y").as_ref(), "tol_dir_y", self.direction.y),
edit(t!("Direction Z").as_ref(), "tol_dir_z", self.direction.z),
],
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()
},
)],
},
]
}
@@ -585,9 +673,6 @@ impl PropertyEditable for Tolerance {
"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,
"tol_dir_x" => self.direction.x = v,
"tol_dir_y" => self.direction.y = v,
"tol_dir_z" => self.direction.z = v,
_ => {}
}
}
@@ -923,9 +1008,26 @@ mod tests {
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 = acadrust::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(|r| run_advance(&r.text, r.font, h)).sum();
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| {
@@ -934,11 +1036,17 @@ mod tests {
.unwrap_or(0.0)
})
.sum();
let got = content_width(&cell, h);
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 spacing = letter_spacing(cell.last().unwrap().font, h);
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"