cad-editor/src/entities/mtext.rs
Hakan Seven fb8e2aeec1 feat(properties): make MText columns editable
The Columns row becomes a No columns / Static / Dynamic picker, with
editable column count / width / gutter rows that are live only when
columns are on. Switching a plain MText to columns seeds a consistent
count / width / gutter (dynamic defaults to auto-height) so the stored
layout isn't half-defined; the rows then refine it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 01:14:15 +03:00

589 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use acadrust::entities::{AttachmentPoint, DrawingDirection, MText};
use crate::command::EntityTransform;
use crate::entities::common::{
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, MTextRenderOpts, MTextVAnchor,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::SnapHint;
/// Combined attachment point shown as a single justify dropdown value.
fn attachment_str(a: &AttachmentPoint) -> &'static str {
match a {
AttachmentPoint::TopLeft => "Top left",
AttachmentPoint::TopCenter => "Top center",
AttachmentPoint::TopRight => "Top right",
AttachmentPoint::MiddleLeft => "Middle left",
AttachmentPoint::MiddleCenter => "Middle center",
AttachmentPoint::MiddleRight => "Middle right",
AttachmentPoint::BottomLeft => "Bottom left",
AttachmentPoint::BottomCenter => "Bottom center",
AttachmentPoint::BottomRight => "Bottom right",
}
}
fn attachment_from_justify(value: &str) -> Option<AttachmentPoint> {
Some(match value {
"Top left" => AttachmentPoint::TopLeft,
"Top center" => AttachmentPoint::TopCenter,
"Top right" => AttachmentPoint::TopRight,
"Middle left" => AttachmentPoint::MiddleLeft,
"Middle center" => AttachmentPoint::MiddleCenter,
"Middle right" => AttachmentPoint::MiddleRight,
"Bottom left" => AttachmentPoint::BottomLeft,
"Bottom center" => AttachmentPoint::BottomCenter,
"Bottom right" => AttachmentPoint::BottomRight,
_ => return None,
})
}
fn drawing_dir_str(d: &DrawingDirection) -> &'static str {
match d {
DrawingDirection::LeftToRight => "Left to right",
DrawingDirection::TopToBottom => "Top to bottom",
DrawingDirection::ByStyle => "By style",
}
}
/// Per-visible-character world-space boxes for the MText editor's
/// click-to-select preview. Uses the exact same layout opts as `to_truck`
/// so the boxes line up with the rendered glyphs.
pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec<GlyphBox> {
let resolved_style = resolve_text_style(&t.style, document);
let attach_h_anchor: f32 = match t.attachment_point {
AttachmentPoint::TopCenter
| AttachmentPoint::MiddleCenter
| AttachmentPoint::BottomCenter => 0.5,
AttachmentPoint::TopRight | AttachmentPoint::MiddleRight | AttachmentPoint::BottomRight => {
1.0
}
_ => 0.0,
};
let v_anchor = match t.attachment_point {
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => {
MTextVAnchor::Top
}
AttachmentPoint::MiddleLeft
| AttachmentPoint::MiddleCenter
| AttachmentPoint::MiddleRight => MTextVAnchor::Middle,
AttachmentPoint::BottomLeft
| AttachmentPoint::BottomCenter
| AttachmentPoint::BottomRight => MTextVAnchor::Bottom,
};
let rotation = if resolved_style.is_upside_down {
t.rotation as f32 + std::f32::consts::PI
} else {
t.rotation as f32
};
let layout = layout_mtext(&MTextRenderOpts {
value: &t.value,
insertion: [
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
],
height: t.height as f32,
rect_w: t.rectangle_width as f32,
rotation,
style: &resolved_style,
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
want_glyph_boxes: true,
});
layout.glyph_boxes
}
fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
let resolved_style = resolve_text_style(&t.style, document);
let attach_h_anchor: f32 = match t.attachment_point {
AttachmentPoint::TopCenter
| AttachmentPoint::MiddleCenter
| AttachmentPoint::BottomCenter => 0.5,
AttachmentPoint::TopRight | AttachmentPoint::MiddleRight | AttachmentPoint::BottomRight => {
1.0
}
_ => 0.0,
};
let v_anchor = match t.attachment_point {
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => {
MTextVAnchor::Top
}
AttachmentPoint::MiddleLeft
| AttachmentPoint::MiddleCenter
| AttachmentPoint::MiddleRight => MTextVAnchor::Middle,
AttachmentPoint::BottomLeft
| AttachmentPoint::BottomCenter
| AttachmentPoint::BottomRight => MTextVAnchor::Bottom,
};
let rotation = if resolved_style.is_upside_down {
t.rotation as f32 + std::f32::consts::PI
} else {
t.rotation as f32
};
let layout = layout_mtext(&MTextRenderOpts {
value: &t.value,
insertion: [
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
],
height: t.height as f32,
rect_w: t.rectangle_width as f32,
rotation,
style: &resolved_style,
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
want_glyph_boxes: false,
});
let insertion = glam::DVec3::new(
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
);
TruckEntity {
object: TruckObject::Text(layout.strokes),
snap_pts: vec![(insertion, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
}
}
fn grips(t: &MText) -> Vec<GripDef> {
let p = glam::DVec3::new(
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
);
let dir = glam::DVec3::new(t.rotation.cos(), t.rotation.sin(), 0.0);
let width_grip = p + dir * t.rectangle_width.max(0.0);
vec![square_grip(0, p), triangle_grip(1, width_grip)]
}
fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str {
match c.column_type {
1 => "Static",
2 => "Dynamic",
_ => "No columns",
}
}
fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
// Absolute line-space distance for single spacing (~1.66 * height) scaled
// 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.
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 {
title: "Text".into(),
props: vec![
Property {
label: "Contents".into(),
field: "content",
value: PropValue::EditText(t.value.clone()),
},
Property {
label: "Style".into(),
field: "style",
value: PropValue::Choice {
selected: if t.style.trim().is_empty() {
"Standard".into()
} else {
t.style.clone()
},
options: text_style_names.to_vec(),
},
},
ro(
"Annotative",
"annotative",
if t.is_annotative { "Yes" } else { "No" },
),
Property {
label: "Justify".into(),
field: "justify",
value: PropValue::Choice {
selected: attachment_str(&t.attachment_point).to_string(),
options: [
"Top left",
"Top center",
"Top right",
"Middle left",
"Middle center",
"Middle right",
"Bottom left",
"Bottom center",
"Bottom right",
]
.into_iter()
.map(str::to_string)
.collect(),
},
},
Property {
label: "Direction".into(),
field: "direction",
value: PropValue::Choice {
selected: drawing_dir_str(&t.drawing_direction).to_string(),
options: ["By style", "Left to right", "Top to bottom"]
.into_iter()
.map(str::to_string)
.collect(),
},
},
edit("Text height", "height", t.height),
edit("Rotation", "rotation", t.rotation.to_degrees()),
edit("Line space factor", "line_spacing", t.line_spacing_factor),
edit("Line space distance", "line_space_distance", line_space_distance),
Property {
label: "Line space style".into(),
field: "line_space_style",
value: PropValue::Choice {
selected: match t.line_spacing_style {
acadrust::entities::LineSpacingStyle::Exactly => "Exactly",
_ => "At least",
}
.to_string(),
options: ["At least", "Exactly"]
.into_iter()
.map(str::to_string)
.collect(),
},
},
Property {
label: "Background mask".into(),
field: "background_mask",
value: PropValue::Choice {
selected: if t.background_fill_flags & 0x01 != 0 {
"Fill".into()
} else if t.background_fill_flags & 0x02 != 0 {
"Mask".into()
} else {
"Off".into()
},
options: ["Off", "Fill", "Mask"]
.into_iter()
.map(str::to_string)
.collect(),
},
},
num_row("Defined width", "rect_w", t.rectangle_width, width_editable),
num_row(
"Defined height",
"rect_h",
t.rectangle_height.unwrap_or(0.0),
height_editable,
),
Property {
label: "Columns".into(),
field: "columns",
value: PropValue::Choice {
selected: columns_str(&t.column_data).to_string(),
options: ["No columns", "Static", "Dynamic"]
.into_iter()
.map(str::to_string)
.collect(),
},
},
// Count / width / gutter are live only when columns are on.
num_row(
"Column count",
"col_count",
t.column_data.column_count as f64,
col_type != 0,
),
num_row("Column width", "col_width", t.column_data.width, col_type != 0),
num_row(
"Column gutter",
"col_gutter",
t.column_data.gutter,
col_type != 0,
),
Property {
label: "Text frame".into(),
field: "text_frame",
value: PropValue::BoolToggle {
field: "text_frame",
value: text_frame_on,
},
},
],
},
PropSection {
title: "Geometry".into(),
props: vec![
edit("Position X", "ins_x", t.insertion_point.x),
edit("Position Y", "ins_y", t.insertion_point.y),
edit("Position Z", "ins_z", t.insertion_point.z),
],
},
]
}
fn apply_geom_prop(t: &mut MText, field: &str, value: &str) {
match field {
"content" => {
t.value = value.to_string();
return;
}
"style" => {
t.style = value.to_string();
return;
}
"justify" => {
if let Some(next) = attachment_from_justify(value) {
t.attachment_point = next;
}
return;
}
"background_mask" => {
// Clear both fill bits, then set the chosen one. 0x01 = use the
// background-fill colour, 0x02 = use the drawing-window colour (mask).
t.background_fill_flags &= !0x03;
match value {
"Fill" => t.background_fill_flags |= 0x01,
"Mask" => t.background_fill_flags |= 0x02,
_ => {}
}
return;
}
"text_frame" => {
// Rendered as a checkbox (BoolToggle) → the toggle sends "toggle";
// flip the frame bit. Accept explicit "On"/"Off" too for safety.
match value {
"toggle" => t.background_fill_flags ^= 0x10,
"On" => t.background_fill_flags |= 0x10,
_ => t.background_fill_flags &= !0x10,
}
return;
}
"direction" => {
t.drawing_direction = match value {
"Left to right" => DrawingDirection::LeftToRight,
"Top to bottom" => DrawingDirection::TopToBottom,
"By style" => DrawingDirection::ByStyle,
_ => return,
};
return;
}
"line_space_style" => {
t.line_spacing_style = match value {
"Exactly" => acadrust::entities::LineSpacingStyle::Exactly,
"At least" => acadrust::entities::LineSpacingStyle::AtLeast,
_ => return,
};
return;
}
"columns" => {
let new_type = match value {
"Static" => 1,
"Dynamic" => 2,
_ => 0,
};
// Compute defaults before borrowing column_data mutably.
let default_w = if t.rectangle_width > 0.0 {
t.rectangle_width
} else {
t.height * 10.0
};
let default_gut = t.height.max(1.0);
let cd = &mut t.column_data;
// Turning columns on from none seeds a consistent layout so the
// stored data isn't a half-set column definition; the count / width
// / gutter rows refine it. Dynamic columns default to auto-height.
if new_type != 0 && cd.column_type == 0 {
if cd.column_count < 2 {
cd.column_count = 2;
}
if cd.width <= 0.0 {
cd.width = default_w;
}
if cd.gutter <= 0.0 {
cd.gutter = default_gut;
}
cd.auto_height = new_type == 2;
}
cd.column_type = new_type;
return;
}
_ => {}
}
let Some(v) = crate::entities::common::parse_f64(value) else {
return;
};
match field {
"ins_x" => t.insertion_point.x = v,
"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),
"rotation" => t.rotation = v.to_radians(),
"line_spacing" if v > 0.0 => 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;
}
}
"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,
_ => {}
}
}
fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) {
match (grip_id, apply) {
(0, 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;
}
(0, 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;
}
(1, GripApply::Absolute(p)) => {
let dir_x = t.rotation.cos();
let dir_y = t.rotation.sin();
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.max(0.01);
}
_ => {}
}
}
fn apply_transform(t: &mut MText, tr: &EntityTransform) {
crate::scene::view::transform::apply_standard_entity_transform(t, tr, |entity, p1, p2| {
crate::scene::view::transform::reflect_xy_point(
&mut entity.insertion_point.x,
&mut entity.insertion_point.y,
p1,
p2,
);
let dx = (p2.x - p1.x) as f64;
let dy = (p2.y - p1.y) as f64;
let line_angle = dy.atan2(dx);
entity.rotation = 2.0 * line_angle - entity.rotation;
});
}
impl TruckConvertible for MText {
fn to_truck(&self, document: &acadrust::CadDocument) -> Option<TruckEntity> {
Some(to_truck(self, document))
}
}
impl Grippable for MText {
fn grips(&self) -> Vec<GripDef> {
grips(self)
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
apply_grip(self, grip_id, apply);
}
fn grip_menu(&self, grip_id: usize) -> Vec<crate::scene::model::object::GripMenuItem> {
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
if grip_id == 0 {
// Insertion point
vec![
GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
},
GripMenuItem {
label: "Move with Text",
action: GripMenuAction::MoveWithText,
},
GripMenuItem {
label: "Rotate",
action: GripMenuAction::RotateText,
},
]
} else {
// Width grip
vec![GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
}]
}
}
fn apply_grip_menu(&mut self, _grip_id: usize, _action: crate::scene::model::object::GripMenuAction) {
// Rotate needs a follow-up angle handled by
// `apply_grip_menu_value`; Move-with-Text is the default drag.
}
fn grip_menu_value_prompt(
&self,
_grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
) -> Option<&'static str> {
use crate::scene::model::object::GripMenuAction as A;
match action {
A::RotateText => Some("Rotation (deg)"),
_ => None,
}
}
fn apply_grip_menu_value(
&mut self,
_grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
value: f64,
) {
use crate::scene::model::object::GripMenuAction as A;
if matches!(action, A::RotateText) {
self.rotation = value.to_radians();
}
}
}
impl PropertyEditable for MText {
fn geometry_properties(&self, text_style_names: &[String]) -> Vec<PropSection> {
properties(self, text_style_names)
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
apply_geom_prop(self, field, value);
}
}
impl Transformable for MText {
fn apply_transform(&mut self, t: &EntityTransform) {
apply_transform(self, t);
}
}
impl crate::entities::traits::TextContent for acadrust::entities::MText {
fn text_content(&self) -> Option<String> {
Some(self.value.clone())
}
fn replace_text(&mut self, search: &str, rep: &str) {
let search_lc = search.to_lowercase();
if self.value.to_lowercase().contains(&search_lc) {
self.value = self.value.replace(search, rep);
}
}
}