Complete geometric tolerance workflow and properties

This commit is contained in:
ramox81 2026-08-26 13:54:20 +03:00
commit de294447a9
14 changed files with 1037 additions and 135 deletions

View file

@ -594,10 +594,7 @@ impl OpenCADStudio {
}
"TOLERANCE" => {
use crate::modules::annotate::tolerance_cmd::ToleranceCommand;
let cmd = ToleranceCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
self.open_tolerance_dialog(None);
}
"TABLE" => {

View file

@ -22,6 +22,7 @@ pub(crate) mod settings;
mod shortcuts;
mod style_ops;
mod text_inline;
mod tolerance_dialog;
mod update;
mod view;
mod visibility;
@ -399,6 +400,8 @@ pub(super) struct OpenCADStudio {
layer_translator: Option<crate::ui::window::layer_translator::State>,
/// Working copy of the Drawing Units dialog; `None` while it is closed.
drawing_units: Option<crate::ui::window::drawing_units::State>,
/// Working copy of the structured feature-control-frame editor.
geometric_tolerance: Option<crate::ui::window::geometric_tolerance::State>,
/// PICKDRAG (#226): false (default) = press-drag lassoes; true =
/// press-drag draws a rectangle marquee.
pick_drag_rect: bool,
@ -1588,6 +1591,7 @@ pub enum ModalKind {
LayerStateManager,
LayerTranslator,
DrawingUnits,
GeometricTolerance,
DraftingSettings,
LayerStateEditor,
Plot,
@ -2318,6 +2322,14 @@ pub enum Message {
DrawingUnitsField(crate::ui::window::drawing_units::Field),
/// Drawing Units OK — write the working copy into the drawing.
DrawingUnitsApply,
/// One structured feature-control-frame field changed.
ToleranceDialogField(crate::ui::window::geometric_tolerance::Field),
/// One structured feature-control-frame option changed.
ToleranceDialogToggle(crate::ui::window::geometric_tolerance::Toggle),
/// Apply edits without closing the structured editor.
ToleranceDialogApply,
/// Commit edits or continue to insertion-point placement.
ToleranceDialogOk,
/// Toggle the Isolate pill's action menu open/closed.
ToggleIsolatePopup,
/// Close the Isolate action menu.
@ -3145,6 +3157,7 @@ impl OpenCADStudio {
last_layer_translation: None,
layer_translator: None,
drawing_units: None,
geometric_tolerance: None,
pick_drag_rect: false,
perf_hud: false,
cycle_candidates: None,

View file

@ -2,6 +2,7 @@ use super::helpers::{entity_type_key, entity_type_label, title_case_word};
use super::{OpenCADStudio, VARIES_LABEL};
use crate::io::linetypes;
use crate::scene::view::dispatch;
use crate::scene::model::object::PropValue;
use crate::ui;
use crate::t;
use acadrust::types::{Transform, Vector3};
@ -1310,11 +1311,77 @@ impl OpenCADStudio {
// Feature-control frame: FCF text style is the dimension
// style's DIMTXSTY.
acadrust::EntityType::Tolerance(tol) => {
if let Some(ds) = find_dim_style(doc, &tol.dimension_style_name) {
if !ds.dimtxsty.is_empty() {
set_row(&mut sections, "tol_text_style", ds.dimtxsty.clone());
}
use crate::entities::dim_override as dov;
let style = crate::entities::tolerance::resolve_dim_style(tol, doc);
let style_name = style
.map(|entry| entry.name.clone())
.unwrap_or_else(|| {
if tol.dimension_style_name.trim().is_empty() {
"Standard".to_string()
} else {
tol.dimension_style_name.clone()
}
});
let mut dim_style_names: Vec<String> = doc
.dim_styles
.iter()
.map(|entry| entry.name.clone())
.filter(|name| !name.trim().is_empty())
.collect();
if !dim_style_names
.iter()
.any(|name| name.eq_ignore_ascii_case(&style_name))
{
dim_style_names.push(style_name.clone());
}
set_row_value(
&mut sections,
"tol_dim_style",
PropValue::Choice {
selected: style_name,
options: dim_style_names,
},
);
let text_style_name = dov::handle(
&tol.common.extended_data,
dov::DIMTXSTY,
)
.and_then(|handle| {
doc.text_styles
.iter()
.find(|entry| entry.handle == handle)
})
.map(|entry| entry.name.clone())
.or_else(|| style.map(|entry| entry.dimtxsty.clone()))
.unwrap_or_else(|| "Standard".to_string());
let mut names = text_style_names.clone();
if !names
.iter()
.any(|name| name.eq_ignore_ascii_case(&text_style_name))
{
names.push(text_style_name.clone());
}
set_row_value(
&mut sections,
"tol_text_style",
PropValue::Choice {
selected: text_style_name,
options: names,
},
);
let height = dov::real(
&tol.common.extended_data,
dov::DIMTXT,
)
.or_else(|| style.map(|entry| entry.dimtxt))
.unwrap_or(tol.text_height);
set_row_value(
&mut sections,
"tol_text_height",
PropValue::EditText(format!("{height:.4}")),
);
}
// MultiLeader: max points + segment-angle constraints
// are MLeaderStyle settings, not stored on the entity.

View file

@ -145,6 +145,13 @@ impl super::OpenCADStudio {
if self.tabs[i].scene.is_layer_locked(target) {
return iced::Task::none();
}
if matches!(
self.tabs[i].scene.document.get_entity(target),
Some(EntityType::Tolerance(_))
) {
self.open_tolerance_dialog(Some(target));
return iced::Task::none();
}
// Snapshot what we need before borrowing `self` mutably to open.
let Some(entity) = self.tabs[i].scene.document.get_entity(target) else {
return iced::Task::none();

View file

@ -0,0 +1,88 @@
use acadrust::{EntityType, Handle};
impl super::OpenCADStudio {
pub(super) fn open_tolerance_dialog(&mut self, editing: Option<Handle>) {
let i = self.active_tab;
if editing.is_some_and(|handle| self.tabs[i].scene.is_layer_locked(handle)) {
return;
}
let text = editing
.and_then(|handle| self.tabs[i].scene.document.get_entity(handle))
.and_then(|entity| match entity {
EntityType::Tolerance(tolerance) => Some(tolerance.text.as_str()),
_ => None,
})
.unwrap_or_default();
self.geometric_tolerance = Some(
crate::ui::window::geometric_tolerance::State::from_text(editing, text),
);
self.active_modal = Some(super::ModalKind::GeometricTolerance);
self.modal_offset = iced::Vector::ZERO;
self.modal_resize = iced::Vector::ZERO;
self.modal_content_size = None;
self.modal_drag_last = None;
self.modal_dragging = false;
self.modal_resizing = false;
}
pub(super) fn apply_tolerance_dialog_edit(&mut self) -> bool {
let Some(state) = self.geometric_tolerance.as_ref() else {
return false;
};
let Some(handle) = state.editing else {
return false;
};
if !state.is_valid() || self.tabs[self.active_tab].scene.is_layer_locked(handle) {
return false;
}
let text = state.to_text();
let i = self.active_tab;
let unchanged = self.tabs[i]
.scene
.document
.get_entity(handle)
.is_some_and(|entity| {
matches!(entity, EntityType::Tolerance(tolerance) if tolerance.text == text)
});
if unchanged {
return true;
}
self.push_undo_snapshot(i, "TOLERANCE");
let Some(EntityType::Tolerance(tolerance)) =
self.tabs[i].scene.document.get_entity_mut(handle)
else {
return false;
};
tolerance.text = text;
self.tabs[i]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
self.refresh_properties();
true
}
pub(super) fn begin_tolerance_placement(&mut self) -> bool {
let Some(state) = self.geometric_tolerance.take() else {
return false;
};
if !state.is_valid() || state.editing.is_some() {
self.geometric_tolerance = Some(state);
return false;
}
let i = self.active_tab;
let mut command =
crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(state.to_text());
use crate::command::CadCommand;
let plane = if self.tabs[i].editing_model_space() {
self.tabs[i].ucs_xform().working_plane()
} else {
crate::command::WorkingPlane::default()
};
command.set_working_plane(plane);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.active_modal = None;
true
}
}

View file

@ -1901,7 +1901,73 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
self.push_undo_snapshot(i, "CHPROP");
if field.starts_with("dim_") {
if field == "tol_text_style" {
use crate::entities::dim_override as dov;
use acadrust::xdata::XDataValue;
let style_handle = self.tabs[i]
.scene
.document
.text_styles
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case(&value))
.map(|entry| entry.handle);
if let Some(style_handle) = style_handle {
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle)
|| !matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(acadrust::EntityType::Tolerance(_))
)
{
continue;
}
dov::set(
&mut self.tabs[i].scene.document,
handle,
dov::DIMTXSTY,
Some(XDataValue::Handle(style_handle)),
);
}
}
} else if field == "tol_dim_style" {
let style = self.tabs[i]
.scene
.document
.dim_styles
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case(&value))
.map(|entry| (entry.handle, entry.name.clone(), entry.annotative));
if let Some((style_handle, style_name, annotative)) = style {
let scale = self.tabs[i].scene.creation_annotation_scale_handle();
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
if let Some(acadrust::EntityType::Tolerance(tolerance)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
tolerance.dimension_style_handle = Some(style_handle);
tolerance.dimension_style_name = style_name.clone();
} else {
continue;
}
crate::scene::annotative::set_entity_annotative(
&mut self.tabs[i].scene.document,
handle,
annotative,
);
if annotative {
if let Some(scale) = scale {
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
handle,
scale,
);
}
}
}
}
} else if field.starts_with("dim_") {
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
@ -2552,6 +2618,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
continue;
}
match field {
"tol_text_height" => {
use crate::entities::dim_override as dov;
let trimmed = val.trim();
if trimmed.is_empty() {
dov::set(
&mut self.tabs[i].scene.document,
handle,
dov::DIMTXT,
None,
);
} else if let Ok(height) = trimmed.parse::<f64>() {
if height > 0.0 {
dov::set(
&mut self.tabs[i].scene.document,
handle,
dov::DIMTXT,
Some(acadrust::xdata::XDataValue::Real(height)),
);
}
}
}
_ if field.starts_with("dim_") => {
if matches!(
self.tabs[i].scene.document.get_entity(handle),

View file

@ -195,6 +195,7 @@ impl OpenCADStudio {
self.attr_editor_selected = 0;
self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute;
}
Some(GeometricTolerance) => self.geometric_tolerance = None,
// Closing (✕) discards edits made since the last Apply — matching the
// style editors. Committing happens only through the Apply button.
Some(Aliases) => self.alias_editor_rows.clear(),
@ -3706,6 +3707,40 @@ impl OpenCADStudio {
self.refresh_properties();
Task::none()
}
Message::ToleranceDialogField(field) => {
if let Some(state) = self.geometric_tolerance.as_mut() {
state.apply_field(field);
}
Task::none()
}
Message::ToleranceDialogToggle(toggle) => {
if let Some(state) = self.geometric_tolerance.as_mut() {
state.apply_toggle(toggle);
}
Task::none()
}
Message::ToleranceDialogApply => {
self.apply_tolerance_dialog_edit();
Task::none()
}
Message::ToleranceDialogOk => {
let editing = self
.geometric_tolerance
.as_ref()
.and_then(|state| state.editing)
.is_some();
if editing {
if self.apply_tolerance_dialog_edit() {
self.geometric_tolerance = None;
self.active_modal = None;
self.reset_modal_geometry();
}
} else {
self.begin_tolerance_placement();
self.reset_modal_geometry();
}
Task::none()
}
Message::SetLinearFormat(code) => {
self.units_popup_open = false;
let i = self.active_tab;

View file

@ -21,6 +21,7 @@ impl OpenCADStudio {
Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"),
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(),
Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(),
Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"),
Some(K::Plot) => crate::tr!("modal", "plot"),
@ -234,6 +235,12 @@ impl OpenCADStudio {
crate::ui::window::drawing_units::view_window(state, flow)
})
}
super::super::ModalKind::GeometricTolerance => {
let state = self.geometric_tolerance.as_ref()?;
sized_flow(ex, 670, 530, |flow| {
crate::ui::window::geometric_tolerance::view_window(state, flow)
})
}
super::super::ModalKind::LayerStateManager => {
let states = self.tabs[self.active_tab].scene.document.layer_states();
sized_flow(

View file

@ -4,7 +4,7 @@ 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::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::SnapHint;
use crate::scene::text::lff;
use crate::scene::view::transform;
@ -144,51 +144,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 +151,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 +177,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 +199,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,15 +213,35 @@ 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`
@ -261,11 +251,13 @@ fn content_width(cell: &Cell, height: f32) -> f32 {
/// origin (no origin translation — the wire-builder adds the origin).
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.
@ -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,7 +333,9 @@ 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())
@ -399,11 +405,28 @@ 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);
lff::tessellate_text_ex(
[0.0, 0.0],
h,
0.0,
width_factor,
oblique,
&font,
&text,
);
// Glyph polylines rotated about the run's origin (no origin
// translation — the wire-builder adds `origin`).
let strokes: Vec<Vec<[f32; 2]>> = local_strokes
@ -411,7 +434,7 @@ fn tessellate_tolerance(
.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,
@ -419,6 +442,8 @@ fn tessellate_tolerance(
strokes,
height: h,
rotation: angle,
width_factor,
oblique,
});
run_x += advance;
}
@ -447,6 +472,45 @@ 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 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
@ -457,7 +521,7 @@ impl RenderConvertible for Tolerance {
groups.push(TextStroke {
strokes: box_strokes,
origin: ins,
color: None,
color: frame_color,
fill_tris: vec![],
run: None,
});
@ -469,15 +533,15 @@ impl RenderConvertible for Tolerance {
ins[0] + cell.origin[0] as f64,
ins[1] + cell.origin[1] as f64,
],
color: None,
color: text_color,
fill_tris: vec![],
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,
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 +603,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),
],
},
@ -585,9 +656,29 @@ 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,
"tol_dir_x" | "tol_dir_y" | "tol_dir_z" => {
let mut candidate = self.direction;
match field {
"tol_dir_x" => candidate.x = v,
"tol_dir_y" => candidate.y = v,
_ => candidate.z = v,
}
let direction_len =
(candidate.x * candidate.x + candidate.y * candidate.y + candidate.z * candidate.z)
.sqrt();
let normal_len =
(self.normal.x * self.normal.x + self.normal.y * self.normal.y + self.normal.z * self.normal.z)
.sqrt();
let dot = candidate.x * self.normal.x
+ candidate.y * self.normal.y
+ candidate.z * self.normal.z;
if direction_len > 1.0e-9
&& (normal_len <= 1.0e-9
|| dot.abs() <= direction_len * normal_len * 1.0e-8)
{
self.direction = candidate;
}
}
_ => {}
}
}
@ -923,9 +1014,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 +1042,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"

View file

@ -1,8 +1,6 @@
// TOLERANCE command — place a GD&T (geometric dimensioning & tolerancing) frame.
//
// Workflow:
// 1. Text: Enter tolerance string (e.g. "%%v0.05|A" or plain text)
// 2. Point: Click insertion point
// The structured editor prepares the frame; this command places it.
use acadrust::entities::Tolerance;
use acadrust::types::Vector3;
@ -25,20 +23,15 @@ pub fn tool() -> ToolDef {
}
}
enum Step {
Text,
Insertion { text: String },
}
pub struct ToleranceCommand {
step: Step,
text: String,
plane: WorkingPlane,
}
impl ToleranceCommand {
pub fn new() -> Self {
pub fn with_text(text: String) -> Self {
Self {
step: Step::Text,
text,
plane: WorkingPlane::default(),
}
}
@ -54,36 +47,18 @@ impl CadCommand for ToleranceCommand {
}
fn prompt(&self) -> String {
match &self.step {
Step::Text => t!("TOLERANCE Enter tolerance text:").into_owned(),
Step::Insertion { text } => {
t!("TOLERANCE Specify insertion point [%{text}]:", text = text).into_owned()
}
}
t!("TOLERANCE Specify insertion point:").into_owned()
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Text)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().to_string();
if t.is_empty() {
return Some(CmdResult::Cancel);
}
self.step = Step::Insertion { text: t };
Some(CmdResult::NeedPoint)
false
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if let Step::Insertion { text } = &self.step {
let point = self.plane.to_local(pt);
let ins = Vector3::new(point.x, point.y, point.z);
let tol = Tolerance::with_text(ins, text.clone());
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol)))
} else {
CmdResult::NeedPoint
}
let point = self.plane.to_local(pt);
let ins = Vector3::new(point.x, point.y, point.z);
let tol = Tolerance::with_text(ins, self.text.clone());
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol)))
}
fn on_enter(&mut self) -> CmdResult {
@ -91,17 +66,49 @@ impl CadCommand for ToleranceCommand {
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if !matches!(self.step, Step::Insertion { .. }) {
return None;
let normalized = self
.text
.replace("^J", "\n")
.replace("\\P", "\n")
.replace("\r\n", "\n")
.replace('\r', "\n");
let rows: Vec<Vec<&str>> = normalized
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let mut cells: Vec<&str> = line.split("%%v").collect();
while cells.last().is_some_and(|cell| cell.trim().is_empty()) {
cells.pop();
}
cells
})
.filter(|cells| !cells.is_empty())
.collect();
let row_height = 0.35;
let cell_width = 0.5;
let mut points = Vec::new();
let mut segment = |a: DVec3, b: DVec3| {
if !points.is_empty() {
points.push(DVec3::splat(f64::NAN));
}
points.push(a);
points.push(b);
};
for (row_index, cells) in rows.iter().enumerate() {
let count = cells.len().max(1);
let y0 = -row_height * (row_index as f64 + 0.5);
let y1 = y0 + row_height;
let x1 = cell_width * count as f64;
let p = |x: f64, y: f64| pt + self.plane.x * x + self.plane.y * y;
segment(p(0.0, y0), p(x1, y0));
segment(p(x1, y0), p(x1, y1));
segment(p(x1, y1), p(0.0, y1));
segment(p(0.0, y1), p(0.0, y0));
for index in 1..count {
let x = cell_width * index as f64;
segment(p(x, y0), p(x, y1));
}
}
let d = 0.15;
let points = [
pt - self.plane.x * d,
pt + self.plane.x * d,
DVec3::splat(f64::NAN),
pt - self.plane.y * d,
pt + self.plane.y * d,
];
Some(WireModel {
point_marker: None,
taper_widths: Vec::new(),
@ -119,10 +126,7 @@ impl CadCommand for ToleranceCommand {
dash_align_end: None,
text_verts: Vec::new(),
name: "tolerance_preview".into(),
points: points
.iter()
.map(|point| point.as_vec3().to_array())
.collect(),
points: points.iter().map(|point| point.as_vec3().to_array()).collect(),
points_low: Vec::new(),
color: WireModel::CYAN,
selected: false,

View file

@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection {
],
};
if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) {
if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline))
|| matches!(entity, EntityType::Tolerance(_))
{
section.props.retain(|prop| prop.field != "handle");
}

View file

@ -416,6 +416,7 @@ fn tessellate_entity_inner(
| EntityType::MText(_)
| EntityType::Dimension(_)
| EntityType::MultiLeader(_)
| EntityType::Tolerance(_)
) {
crate::scene::annotative::effective_annotation_scale_for(
document,

View file

@ -0,0 +1,479 @@
//! Structured feature-control-frame editor.
//!
//! The entity stores a compact escape string, but users work with named
//! symbols, tolerance compartments and datum references. This dialog keeps
//! that storage detail behind typed controls and can also reopen existing
//! frames without losing their compartment layout.
use std::fmt;
use acadrust::Handle;
use iced::widget::{button, checkbox, column, container, row, text, text_input, Space};
use iced::{Border, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::t;
const EMPTY: &str = "";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ToleranceEntry {
pub diameter: bool,
pub value: String,
pub material: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DatumEntry {
pub value: String,
pub material: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct State {
pub editing: Option<Handle>,
pub symbol: String,
pub tolerances: [ToleranceEntry; 2],
pub datums: [DatumEntry; 3],
pub projected_height: String,
pub projected_zone: bool,
pub datum_identifier: String,
}
impl Default for State {
fn default() -> Self {
Self {
editing: None,
symbol: String::new(),
tolerances: std::array::from_fn(|_| ToleranceEntry::default()),
datums: std::array::from_fn(|_| DatumEntry::default()),
projected_height: String::new(),
projected_zone: false,
datum_identifier: String::new(),
}
}
}
#[derive(Clone, Debug)]
pub enum Field {
Symbol(String),
ToleranceValue(usize, String),
ToleranceMaterial(usize, String),
DatumValue(usize, String),
DatumMaterial(usize, String),
ProjectedHeight(String),
DatumIdentifier(String),
}
#[derive(Clone, Debug)]
pub enum Toggle {
Diameter(usize, bool),
ProjectedZone(bool),
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Choice {
code: &'static str,
label: &'static str,
}
impl fmt::Display for Choice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(crate::i18n::translate(self.label).as_ref())
}
}
const SYMBOLS: [(&str, &str); 15] = [
("", "None"),
("u", "Straightness"),
("c", "Flatness"),
("e", "Circularity"),
("g", "Cylindricity"),
("d", "Profile of a surface"),
("k", "Profile of a line"),
("j", "Position"),
("r", "Concentricity"),
("i", "Symmetry"),
("f", "Parallelism"),
("b", "Perpendicularity"),
("a", "Angularity"),
("h", "Circular runout"),
("t", "Total runout"),
];
const MATERIALS: [(&str, &str); 4] = [
("", "None"),
("m", "Maximum material condition"),
("l", "Least material condition"),
("s", "Regardless of feature size"),
];
fn choices(values: &'static [(&'static str, &'static str)]) -> Vec<Choice> {
values
.iter()
.map(|(code, label)| Choice { code, label })
.collect()
}
fn selected(values: &'static [(&'static str, &'static str)], code: &str) -> Option<Choice> {
values
.iter()
.find(|(value, _)| value.eq_ignore_ascii_case(code))
.map(|(code, label)| Choice { code, label })
}
fn escape(code: &str) -> String {
if code.is_empty() {
String::new()
} else {
format!("{{\\Fgdt;{code}}}")
}
}
fn strip_escape(input: &str, code: &str) -> Option<String> {
let marker = escape(code);
input
.strip_suffix(&marker)
.map(std::string::ToString::to_string)
}
fn parse_material(input: &str) -> (String, String) {
for code in ["m", "l", "s"] {
if let Some(value) = strip_escape(input, code) {
return (value, code.to_string());
}
}
(input.to_string(), String::new())
}
impl State {
pub fn from_text(editing: Option<Handle>, raw: &str) -> Self {
let mut state = Self {
editing,
..Self::default()
};
let normalized = raw
.replace("^J", "\n")
.replace("\\P", "\n")
.replace("\r\n", "\n")
.replace('\r', "\n");
let mut lines = normalized.lines();
if let Some(frame) = lines.next() {
let cells: Vec<&str> = frame.split("%%v").collect();
if let Some(cell) = cells.first() {
state.symbol = SYMBOLS
.iter()
.find_map(|(code, _)| {
(!code.is_empty() && cell.contains(&escape(code))).then(|| code.to_string())
})
.unwrap_or_default();
}
for index in 0..2 {
if let Some(cell) = cells.get(index + 1) {
let (cell, diameter) = if let Some(rest) = cell.strip_prefix(&escape("n")) {
(rest, true)
} else {
(*cell, false)
};
let (value, material) = parse_material(cell);
state.tolerances[index] = ToleranceEntry {
diameter,
value,
material,
};
}
}
for index in 0..3 {
if let Some(cell) = cells.get(index + 3) {
let (value, material) = parse_material(cell);
state.datums[index] = DatumEntry { value, material };
}
}
if cells.len() == 1
&& state.symbol.is_empty()
&& !frame.trim().is_empty()
{
state.tolerances[0].value = frame.trim().to_string();
}
}
if let Some(projected) = lines.next() {
if let Some(value) = strip_escape(projected, "p") {
state.projected_height = value;
state.projected_zone = true;
} else {
state.projected_height = projected.to_string();
}
}
if let Some(identifier) = lines.next() {
state.datum_identifier = identifier.to_string();
}
state
}
pub fn apply_field(&mut self, field: Field) {
match field {
Field::Symbol(value) => self.symbol = value,
Field::ToleranceValue(index, value) if index < 2 => {
self.tolerances[index].value = value
}
Field::ToleranceMaterial(index, value) if index < 2 => {
self.tolerances[index].material = value
}
Field::DatumValue(index, value) if index < 3 => self.datums[index].value = value,
Field::DatumMaterial(index, value) if index < 3 => {
self.datums[index].material = value
}
Field::ProjectedHeight(value) => self.projected_height = value,
Field::DatumIdentifier(value) => self.datum_identifier = value,
_ => {}
}
}
pub fn apply_toggle(&mut self, toggle: Toggle) {
match toggle {
Toggle::Diameter(index, value) if index < 2 => {
self.tolerances[index].diameter = value
}
Toggle::ProjectedZone(value) => self.projected_zone = value,
_ => {}
}
}
pub fn is_valid(&self) -> bool {
!self.symbol.is_empty()
|| self.tolerances.iter().any(|entry| !entry.value.trim().is_empty())
|| self.datums.iter().any(|entry| !entry.value.trim().is_empty())
|| !self.projected_height.trim().is_empty()
|| !self.datum_identifier.trim().is_empty()
}
pub fn to_text(&self) -> String {
let mut cells = Vec::with_capacity(6);
cells.push(escape(&self.symbol));
for entry in &self.tolerances {
let mut value = String::new();
if entry.diameter && !entry.value.trim().is_empty() {
value.push_str(&escape("n"));
}
value.push_str(entry.value.trim());
if !entry.value.trim().is_empty() {
value.push_str(&escape(&entry.material));
}
cells.push(value);
}
for entry in &self.datums {
let mut value = entry.value.trim().to_string();
if !value.is_empty() {
value.push_str(&escape(&entry.material));
}
cells.push(value);
}
while cells.last().is_some_and(String::is_empty) {
cells.pop();
}
let mut rows = vec![cells.join("%%v")];
if !self.projected_height.trim().is_empty() || self.projected_zone {
let mut projected = self.projected_height.trim().to_string();
if self.projected_zone {
projected.push_str(&escape("p"));
}
rows.push(projected);
}
if !self.datum_identifier.trim().is_empty() {
if rows.len() == 1 {
rows.push(String::new());
}
rows.push(self.datum_identifier.trim().to_string());
}
rows.join("\n")
}
}
fn muted(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.65)),
}
}
fn panel<'a>(title: String, body: Element<'a, Message>) -> Element<'a, Message> {
container(column![text(title).size(11).style(muted), body].spacing(6))
.padding(8)
.width(Fill)
.style(|theme: &Theme| container::Style {
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
})
.into()
}
fn material_picker<'a>(index: usize, datum: bool, code: &str) -> Element<'a, Message> {
let options = choices(&MATERIALS);
let selected = selected(&MATERIALS, code);
iced::widget::pick_list(selected, options, |choice| choice.to_string())
.on_select(move |choice| {
if datum {
Message::ToleranceDialogField(Field::DatumMaterial(
index,
choice.code.to_string(),
))
} else {
Message::ToleranceDialogField(Field::ToleranceMaterial(
index,
choice.code.to_string(),
))
}
})
.text_size(11)
.padding([3, 6])
.width(Length::Fixed(190.0))
.into()
}
pub fn view_window<'a>(
state: &State,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let symbol_options = choices(&SYMBOLS);
let symbol_selected = selected(&SYMBOLS, &state.symbol);
let symbol = panel(
t!("Geometric characteristic").into_owned(),
iced::widget::pick_list(symbol_selected, symbol_options, |choice| choice.to_string())
.on_select(|choice| {
Message::ToleranceDialogField(Field::Symbol(choice.code.to_string()))
})
.text_size(12)
.padding([4, 6])
.width(Fill)
.into(),
);
let tolerance_rows = state.tolerances.iter().enumerate().fold(
column![].spacing(6),
|column, (index, entry)| {
column.push(
row![
text(format!("{} {}", t!("Tolerance"), index + 1))
.size(11)
.style(muted)
.width(Length::Fixed(82.0)),
checkbox(entry.diameter)
.on_toggle(move |value| Message::ToleranceDialogToggle(
Toggle::Diameter(index, value)
))
.size(14),
text(t!("Diameter")).size(11).width(Length::Fixed(62.0)),
text_input(EMPTY, &entry.value)
.on_input(move |value| Message::ToleranceDialogField(
Field::ToleranceValue(index, value)
))
.size(12)
.padding([3, 6])
.width(Length::Fixed(125.0)),
material_picker(index, false, &entry.material),
]
.spacing(6)
.align_y(iced::Center),
)
},
);
let tolerances = panel(t!("Tolerance values").into_owned(), tolerance_rows.into());
let datum_rows = state.datums.iter().enumerate().fold(
column![].spacing(6),
|column, (index, entry)| {
column.push(
row![
text(format!("{} {}", t!("Datum"), index + 1))
.size(11)
.style(muted)
.width(Length::Fixed(82.0)),
text_input(EMPTY, &entry.value)
.on_input(move |value| Message::ToleranceDialogField(
Field::DatumValue(index, value)
))
.size(12)
.padding([3, 6])
.width(Length::Fixed(212.0)),
material_picker(index, true, &entry.material),
]
.spacing(6)
.align_y(iced::Center),
)
},
);
let datums = panel(t!("Datum references").into_owned(), datum_rows.into());
let additions = panel(
t!("Additional information").into_owned(),
column![
row![
text(t!("Projected height"))
.size(11)
.style(muted)
.width(Length::Fixed(112.0)),
text_input(EMPTY, &state.projected_height)
.on_input(|value| Message::ToleranceDialogField(Field::ProjectedHeight(value)))
.size(12)
.padding([3, 6])
.width(Length::Fixed(125.0)),
checkbox(state.projected_zone)
.on_toggle(|value| Message::ToleranceDialogToggle(
Toggle::ProjectedZone(value)
))
.size(14),
text(t!("Projected tolerance zone")).size(11),
]
.spacing(6)
.align_y(iced::Center),
row![
text(t!("Datum identifier"))
.size(11)
.style(muted)
.width(Length::Fixed(112.0)),
text_input(EMPTY, &state.datum_identifier)
.on_input(|value| Message::ToleranceDialogField(Field::DatumIdentifier(value)))
.size(12)
.padding([3, 6])
.width(Length::Fixed(212.0)),
]
.spacing(6)
.align_y(iced::Center),
]
.spacing(6)
.into(),
);
let mut actions = row![
Space::new().width(Fill),
button(text(t!("Cancel")).size(12))
.on_press(Message::CloseModal)
.padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center);
if state.editing.is_some() {
actions = actions.push(
button(text(t!("Apply")).size(12))
.on_press_maybe(state.is_valid().then_some(Message::ToleranceDialogApply))
.padding([4, 12]),
);
}
actions = actions.push(
button(text(t!("OK")).size(12))
.on_press_maybe(state.is_valid().then_some(Message::ToleranceDialogOk))
.style(button::primary)
.padding([4, 12]),
);
column![symbol, tolerances, datums, additions, actions]
.spacing(8)
.padding(10)
.width(sizing.width)
.into()
}

View file

@ -3,6 +3,7 @@ pub mod block_palette;
pub mod layout_manager;
pub mod layer_state_manager;
pub mod drawing_units;
pub mod geometric_tolerance;
pub mod drafting_settings;
pub mod layer_translator;
pub mod plot;