diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 19a762126..9303281a4 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -233,9 +233,11 @@ impl OpenCADStudio { "DSETTINGS" | "OSNAP" => { return Some(Task::done(Message::ToggleSnapPopup)); } - // UNITS — open the drawing-units picker (linear / angular format). + // UNITS — length and angle formats, plus the insertion unit. The + // status-bar button covers the length format alone; everything else + // about how this drawing writes numbers is here. "UNITS" | "DDUNITS" => { - return Some(Task::done(Message::ToggleUnitsPopup)); + return Some(Task::done(Message::OpenDrawingUnits)); } // ── CLEANSCREEN — collapse the surrounding panels for a full canvas ── diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index 7a138a84d..29296ce65 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -78,6 +78,41 @@ impl OpenCADStudio { } } + // DWGUNITS — change what one drawing unit measures, and convert the + // model to match. The units pill only relabels, which is all the + // label is for; this is the one place geometry moves. (#668) + "DWGUNITS" => { + use crate::command::KeywordCommand; + use crate::modules::draw::units; + // Buttons read as full names; the token behind each is the + // abbreviation, since that is what gets typed. Unitless is left + // off — there is nothing to convert to. + let choices: Vec<(&str, &str, Option<&str>)> = units::all() + .filter(|&(code, _)| code != 0) + .map(|(code, label)| (label, units::short(code), None)) + .collect(); + // Built once: the table it comes from never changes, and + // `KeywordCommand` wants a `&'static str`. + static PROMPT: std::sync::OnceLock = std::sync::OnceLock::new(); + let prompt = PROMPT.get_or_init(|| { + let listed = units::all() + .filter(|&(code, _)| code != 0) + .map(|(code, label)| format!("{label}({})", units::short(code))) + .collect::>() + .join(" / "); + format!("DWGUNITS convert to [{listed}]:") + }); + let current = units::label(self.tabs[i].scene.document.header.insertion_units); + self.command_line + .push_info(crate::tf!("DWGUNITS current unit: {current}").as_ref()); + let c = KeywordCommand::new("DWGUNITS", prompt.as_str(), choices); + self.command_line.push_info(&c.prompt()); + self.tabs[i].active_cmd = Some(Box::new(c)); + } + cmd if cmd.starts_with("DWGUNITS ") => { + return Some(self.convert_drawing_units(cmd.trim_start_matches("DWGUNITS").trim())); + } + // LAYTRANS — translate this drawing's layers onto a set taken from // another drawing. Bare opens the mapping dialog; with a file it // maps every name the two drawings share and translates at once, @@ -716,6 +751,153 @@ impl OpenCADStudio { self.finish_layer_translation(i, report) } + /// Relabel the drawing's unit and convert the model to suit. + /// + /// The label always changes — that is what was asked for. The geometry + /// changes with it whenever both units measure something, which is what + /// makes this different from picking a unit off the status bar. Unitless + /// measures nothing, so a drawing coming from or going to it is relabelled + /// and left alone. + pub(in crate::app) fn convert_drawing_units(&mut self, name: &str) -> Task { + use crate::command::EntityTransform; + use crate::modules::draw::units; + + let i = self.active_tab; + let Some(to) = units::code_for_keyword(name) else { + self.command_line + .push_error(crate::t!("DWGUNITS: not a unit this drawing can use.").as_ref()); + return Task::none(); + }; + let from = self.tabs[i].scene.document.header.insertion_units; + if from == to { + let label = units::label(to); + self.command_line + .push_output(crate::tf!("DWGUNITS: already in {label}.").as_ref()); + return Task::none(); + } + + self.push_undo_snapshot(i, "DWGUNITS"); + self.tabs[i].scene.document.header.insertion_units = to; + + let factor = units::conversion_factor(from, to).filter(|f| (f - 1.0).abs() > 1e-12); + let (from_label, to_label) = (units::label(from), units::label(to)); + let Some(factor) = factor else { + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!( + "DWGUNITS: now {to_label}. Nothing was converted — {from_label} has no size to convert from." + ) + .as_ref(), + ); + return Task::none(); + }; + + let handles = units::model_space_handles(&self.tabs[i].scene); + let moved = handles.len(); + // A locked layer is protected from editing, not from the drawing + // changing what its numbers mean. Converting all but the locked layers + // would leave the drawing at two scales at once, so the locks are + // lifted for the duration and put back. + let relocked: Vec = self.tabs[i] + .scene + .document + .layers + .iter() + .filter(|layer| layer.is_locked()) + .map(|layer| layer.name.clone()) + .collect(); + for name in &relocked { + if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) { + layer.flags.locked = false; + } + } + self.tabs[i].scene.transform_entities( + &handles, + &EntityTransform::Scale { + center: glam::DVec3::ZERO, + factor, + }, + ); + for name in &relocked { + if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) { + layer.flags.locked = true; + } + } + + // A viewport frames the model, so its framing is measured in model + // units and has to follow them. Left alone, every layout would suddenly + // look at a region a thousand times too large. The paper side of the + // ratio is untouched — the sheet is still the same sheet — so the ratio + // itself moves the other way. + // + // `transform_viewport` covers the rectangle and the target but not the + // framing, so the framing is done here for every viewport, and the + // target only for the ones the scale above did not already reach. + let scaled: std::collections::HashSet = handles.iter().copied().collect(); + let mut reframed = 0usize; + for entity in self.tabs[i].scene.document.entities_mut() { + let handle = entity.common().handle; + let acadrust::entities::EntityType::Viewport(vp) = entity else { + continue; + }; + vp.view_center = vp.view_center * factor; + vp.view_height *= factor; + if vp.custom_scale.abs() > 1e-12 { + vp.custom_scale /= factor; + } + if !scaled.contains(&handle) { + vp.view_target = vp.view_target * factor; + } + reframed += 1; + } + // Sizes the drawing keeps as settings rather than as geometry: dash + // lengths, default heights and widths, the radii the fillet and chamfer + // commands start from. They are all lengths in the unit that just + // changed, so they change with it or the next line drawn comes out at + // the old scale. Angles and screen-relative sizes are left alone — + // PDSIZE is a percentage of the screen when negative. + let header = &mut self.tabs[i].scene.document.header; + for length in [ + &mut header.linetype_scale, + &mut header.current_entity_linetype_scale, + &mut header.text_height, + &mut header.trace_width, + &mut header.sketch_increment, + &mut header.thickness, + &mut header.polyline_width, + &mut header.fillet_radius, + &mut header.chamfer_distance_a, + &mut header.chamfer_distance_b, + &mut header.chamfer_length, + ] { + *length *= factor; + } + if header.point_display_size > 0.0 { + header.point_display_size *= factor; + } + + // A dimension style holds its text height and arrow size in drawing + // units and multiplies them by DIMSCALE, so moving that one factor + // rescales the whole style. Zero means "take it from the viewport", + // which is not a length and stays as it is. + for style in self.tabs[i].scene.document.dim_styles.iter_mut() { + if style.dimscale.abs() > 1e-12 { + style.dimscale *= factor; + } + } + + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!( + "DWGUNITS: {from_label} to {to_label} — {moved} object(s) scaled by {factor}, {reframed} viewport(s) reframed." + ) + .as_ref(), + ); + Task::none() + } + /// Open the translator, optionally with a standard already loaded. pub(in crate::app) fn open_layer_translator( &mut self, diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index c35ef2e6c..555a58107 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -298,6 +298,7 @@ inventory::submit!(crate::command::CommandRegistration { // Layer management. "LAYDEL", "LAYTRANS", + "DWGUNITS", "LAYMRG", "LAYERSTATE", "LAS", diff --git a/src/app/mod.rs b/src/app/mod.rs index 82f8c8d77..f7ae64bf6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -408,6 +408,8 @@ pub(super) struct OpenCADStudio { last_layer_translation: Option, /// The layer-translator dialog's working state while it is open. layer_translator: Option, + /// Working copy of the Drawing Units dialog; `None` while it is closed. + drawing_units: Option, /// PICKDRAG (#226): false (default) = press-drag lassoes; true = /// press-drag draws a rectangle marquee. pick_drag_rect: bool, @@ -1495,6 +1497,7 @@ pub enum ModalKind { Layers, LayerStateManager, LayerTranslator, + DrawingUnits, LayerStateEditor, Plot, PrintAll, @@ -2196,6 +2199,14 @@ pub enum Message { CloseUnitsPopup, /// Set the drawing units (INSUNITS) for the active drawing. SetDrawingUnits(i16), + /// LUNITS — how lengths are written, from the status-bar units button. + SetLinearFormat(i16), + /// Open the Drawing Units dialog, seeded from the active drawing. + OpenDrawingUnits, + /// One field of the Drawing Units dialog changed. + DrawingUnitsField(crate::ui::window::drawing_units::Field), + /// Drawing Units OK — write the working copy into the drawing. + DrawingUnitsApply, /// Toggle the Isolate pill's action menu open/closed. ToggleIsolatePopup, /// Close the Isolate action menu. @@ -3009,6 +3020,7 @@ impl OpenCADStudio { select_remove_mode: false, last_layer_translation: None, layer_translator: None, + drawing_units: None, pick_drag_rect: false, perf_hud: false, cycle_candidates: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b70f2fc0f..969866370 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3534,11 +3534,107 @@ impl OpenCADStudio { self.units_popup_open = false; Task::none() } + Message::OpenDrawingUnits => { + self.units_popup_open = false; + let header = &self.tabs[self.active_tab].scene.document.header; + self.drawing_units = Some(crate::ui::window::drawing_units::State { + linear_format: header.linear_unit_format, + linear_precision: header.linear_unit_precision, + angular_format: header.angular_unit_format, + angular_precision: header.angular_unit_precision, + clockwise: header.angle_direction != 0, + base_angle: format!("{:.6}", header.angle_base.to_degrees()) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string(), + insertion_units: header.insertion_units, + }); + self.active_modal = Some(crate::app::ModalKind::DrawingUnits); + Task::none() + } + Message::DrawingUnitsField(field) => { + use crate::ui::window::drawing_units::Field; + let Some(state) = self.drawing_units.as_mut() else { + return Task::none(); + }; + match field { + Field::LinearFormat(v) => state.linear_format = v, + Field::LinearPrecision(v) => state.linear_precision = v, + Field::AngularFormat(v) => state.angular_format = v, + Field::AngularPrecision(v) => state.angular_precision = v, + Field::Clockwise(v) => state.clockwise = v, + // Kept as typed so a lone "-" or a trailing "." survives + // until the number it is becoming is finished. + Field::BaseAngle(v) => state.base_angle = v, + Field::InsertionUnits(v) => state.insertion_units = v, + } + Task::none() + } + Message::DrawingUnitsApply => { + let Some(state) = self.drawing_units.take() else { + self.active_modal = None; + return Task::none(); + }; + self.active_modal = None; + let i = self.active_tab; + self.push_undo_snapshot(i, "UNITS"); + let header = &mut self.tabs[i].scene.document.header; + header.linear_unit_format = state.linear_format; + header.linear_unit_precision = state.linear_precision; + header.angular_unit_format = state.angular_format; + header.angular_unit_precision = state.angular_precision; + header.angle_direction = i16::from(state.clockwise); + // A base angle that will not parse is a half-finished edit, not + // an instruction to move zero — leave the drawing's own value. + if let Ok(degrees) = state.base_angle.trim().parse::() { + header.angle_base = degrees.to_radians(); + } + header.insertion_units = state.insertion_units; + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.refresh_properties(); + Task::none() + } + Message::SetLinearFormat(code) => { + self.units_popup_open = false; + let i = self.active_tab; + if self.tabs[i].scene.document.header.linear_unit_format == code { + return Task::none(); + } + // Every displayed length is written through this, so the whole + // drawing re-reads at once — no geometry moves, only the way it + // is written down. + self.push_undo_snapshot(i, "LUNITS"); + self.tabs[i].scene.document.header.linear_unit_format = code; + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.refresh_properties(); + let label = crate::modules::draw::units::linear_format_label(code); + self.command_line + .push_output(crate::tf!("Length format is now {label}.").as_ref()); + Task::none() + } Message::SetDrawingUnits(code) => { self.units_popup_open = false; let i = self.active_tab; + let current = self.tabs[i].scene.document.header.insertion_units; + if current == code { + return Task::none(); + } + // Relabelling only: the geometry keeps every number it had, and + // now says they count something else. Say so, because picking a + // unit and seeing the drawing sit still otherwise reads as the + // menu having done nothing. (#668) + self.push_undo_snapshot(i, "UNITS"); self.tabs[i].scene.document.header.insertion_units = code; self.tabs[i].dirty = true; + let label = crate::modules::draw::units::label(code); + self.command_line.push_output( + crate::tf!( + "Drawing unit is now {label}. Geometry unchanged — use DWGUNITS to convert it." + ) + .as_ref(), + ); Task::none() } Message::ToggleIsolatePopup => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 5d1ec1c8d..97aab240d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1658,6 +1658,19 @@ impl OpenCADStudio { let active_block = tab .active_block_edit_session() .map(|session| session.block_name.clone()); + // The coordinate readout formats through the same helper + // the properties panel uses, so it has to see the drawing's + // linear-unit settings. Properties happens to set this on + // every refresh; the status bar redraws on its own schedule + // and cannot rely on that having happened. + crate::entities::common::set_unit_context( + crate::entities::common::UnitContext { + lunits: tab.scene.document.header.linear_unit_format, + luprec: tab.scene.document.header.linear_unit_precision, + aunits: tab.scene.document.header.angular_unit_format, + auprec: tab.scene.document.header.angular_unit_precision, + }, + ); let status_menu_data = crate::ui::statusbar::StatusMenuData { layout_names: layout_names.clone(), polar_custom_input: &self.polar_custom_input, @@ -1703,7 +1716,7 @@ impl OpenCADStudio { last_coord, picking, self.clean_screen, - tab.scene.document.header.insertion_units, + tab.scene.document.header.linear_unit_format, tab.scene.is_isolation_active(), tab.scene.transparency_display, self.quick_properties, diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 71a2b1de6..2b1c1ffb9 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -20,6 +20,7 @@ impl OpenCADStudio { Some(K::Layers) => crate::tr!("modal-layer-manager"), 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::LayerStateEditor) => crate::tr!("modal-edit-layer-state"), Some(K::Plot) => crate::tr!("modal-plot"), Some(K::PrintAll) => t!("Print All").into_owned(), @@ -185,6 +186,12 @@ impl OpenCADStudio { ) }) } + super::super::ModalKind::DrawingUnits => { + let state = self.drawing_units.as_ref()?; + sized_flow(ex, 560, 420, |flow| { + crate::ui::window::drawing_units::view_window(state, flow) + }) + } super::super::ModalKind::LayerStateManager => { let states = self.tabs[self.active_tab].scene.document.layer_states(); sized_flow( diff --git a/src/entities/common.rs b/src/entities/common.rs index b40c8aac4..f0933e33c 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -11,8 +11,8 @@ pub struct UnitContext { pub lunits: i16, /// LUPREC — decimal places (linear) pub luprec: i16, - /// AUNITS — 0=Decimal degrees, 1=DMS, 2=Grad, 3=Rad. Surfaced via - /// `format_angle`, which is read on demand by code that already + /// AUNITS — 0=Decimal degrees, 1=DMS, 2=Grad, 3=Rad, 4=Surveyor. Surfaced + /// via `format_angle`, which is read on demand by code that already /// formats angular values via radians. #[allow(dead_code)] pub aunits: i16, @@ -98,26 +98,63 @@ pub fn format_angle(value_rad: f64) -> String { let ctx = unit_context(); let prec = ctx.auprec.max(0) as usize; match ctx.aunits { - 1 => { - // DMS — degrees / minutes / seconds. - let deg = value_rad.to_degrees(); - let sign = if deg < 0.0 { "-" } else { "" }; - let a = deg.abs(); - let d = a.floor(); - let m_full = (a - d) * 60.0; - let m = m_full.floor(); - let s = (m_full - m) * 60.0; - format!("{}{:.0}°{:.0}'{:.*}\"", sign, d, m, prec, s) - } + 1 => dms(value_rad.to_degrees(), prec), 2 => { let g = value_rad.to_degrees() / 0.9; format!("{:.*}g", prec, g) } 3 => format!("{:.*}r", prec, value_rad), + 4 => surveyor(value_rad, prec), _ => format!("{:.*}°", prec, value_rad.to_degrees()), } } +/// Degrees / minutes / seconds. Each part carries its own mark — `d`, `'`, `"` +/// — so the written angle says which convention it is in. +fn dms(degrees: f64, prec: usize) -> String { + let sign = if degrees < 0.0 { "-" } else { "" }; + let a = degrees.abs(); + let d = a.floor(); + let m_full = (a - d) * 60.0; + let m = m_full.floor(); + let s = (m_full - m) * 60.0; + format!("{}{:.0}d{:.0}'{:.*}\"", sign, d, m, prec, s) +} + +/// Surveyor's units: a bearing away from north or south, toward east or west, +/// so the angle is never more than a quarter turn — `N 45d0'0" E`. +/// +/// Due north, south, east and west have no bearing to quote and are written as +/// the single letter, which is also what keeps a 90° angle from reading as the +/// contradictory `N 90d0'0" E`. +fn surveyor(value_rad: f64, prec: usize) -> String { + let deg = value_rad.to_degrees().rem_euclid(360.0); + let near = |target: f64| (deg - target).abs() < 1e-9; + if near(0.0) { + return "E".into(); + } + if near(90.0) { + return "N".into(); + } + if near(180.0) { + return "W".into(); + } + if near(270.0) { + return "S".into(); + } + // Measured from the nearer pole, toward the side the angle falls on. + let (pole, bearing, side) = if deg < 90.0 { + ("N", 90.0 - deg, "E") + } else if deg < 180.0 { + ("N", deg - 90.0, "W") + } else if deg < 270.0 { + ("S", 270.0 - deg, "W") + } else { + ("S", deg - 270.0, "E") + }; + format!("{pole} {} {side}", dms(bearing, prec)) +} + /// Two interior triangles covering a quad (flat list, 6 vertices) — the /// click-anywhere pick surface for frame-like entities (image, OLE frame, /// underlay, wipeout). Corners in ring order. diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index 4f678d2b6..1f265e43c 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -6,6 +6,7 @@ pub mod defaults; mod donate; pub mod draw; pub mod fence; +pub mod units; pub mod groups; pub mod inquiry; pub mod layers; diff --git a/src/modules/draw/units.rs b/src/modules/draw/units.rs new file mode 100644 index 000000000..77e74abbc --- /dev/null +++ b/src/modules/draw/units.rs @@ -0,0 +1,159 @@ +// Drawing units — three separate questions the word "units" runs together, and +// the tables that answer them. (#668) +// +// *How lengths read* is LUNITS: decimal, architectural, and so on. It is the +// only one of the three that shows, since every coordinate and distance the +// drawing displays is written in that format, which is why it is what the +// status-bar button offers. +// +// *What one unit measures* is INSUNITS. A drawing's geometry is plain numbers; +// this is a label saying what those numbers count. Nothing moves when the label +// changes — its job is to tell *other* drawings how to read this one, which is +// what scales a block or an xref on the way in. +// +// *Converting* is therefore a separate act from relabelling, and the only one +// that touches geometry. It reads the same table as the label, so a unit the +// picker offers is a unit DWGUNITS can convert to. + +// ── LUNITS — how lengths are written ─────────────────────────────────────── + +/// LUNITS code, the name it is offered under, the short form for the +/// status-bar button, and a sample of the format so the choice can be +/// recognised rather than remembered. +const LINEAR_FORMATS: &[(i16, &str, &str, &str)] = &[ + (4, "Architectural", "Arch", "2'9 1/2\""), + (2, "Decimal", "Dec", "33.5000"), + (3, "Engineering", "Eng", "2'-9.5000\""), + (5, "Fractional", "Frac", "33 1/2"), + (1, "Scientific", "Sci", "3.3500E+01"), +]; + +/// Every linear format, in picker order: code, name, sample. +pub fn linear_formats() -> impl Iterator { + LINEAR_FORMATS + .iter() + .map(|&(code, label, _, sample)| (code, label, sample)) +} + +/// The name of a linear format code. +pub fn linear_format_label(code: i16) -> &'static str { + LINEAR_FORMATS + .iter() + .find(|&&(candidate, ..)| candidate == code) + .map(|&(_, label, _, _)| label) + .unwrap_or("Decimal") +} + +/// The short form shown on the status-bar button. +pub fn linear_format_short(code: i16) -> &'static str { + LINEAR_FORMATS + .iter() + .find(|&&(candidate, ..)| candidate == code) + .map(|&(_, _, short, _)| short) + .unwrap_or("Dec") +} + +// ── AUNITS — how angles are written ──────────────────────────────────────── + +/// AUNITS code, the name it is offered under, and a sample of the format. +/// +/// Each convention carries its own mark so a written angle says which one it +/// is: `g` for grads, `r` for radians, `d`/`'`/`"` for degrees-minutes-seconds, +/// and a bearing between two compass letters for surveyor's units. +const ANGULAR_FORMATS: &[(i16, &str, &str)] = &[ + (0, "Decimal Degrees", "45.0000"), + (1, "Deg/Min/Sec", "45d0'0\""), + (2, "Grads", "50.0000g"), + (3, "Radians", "0.7854r"), + (4, "Surveyor's Units", "N 45d0'0\" E"), +]; + +/// Every angular format, in picker order: code, name, sample. +pub fn angular_formats() -> impl Iterator { + ANGULAR_FORMATS.iter().copied() +} + +// ── INSUNITS — what one unit measures ────────────────────────────────────── + +/// INSUNITS code, the name it is offered under, and how many metres one unit +/// measures. `None` for unitless, which measures nothing and so converts to +/// nothing. +const UNITS: &[(i16, &str, &str, Option)] = &[ + (0, "Unitless", "Unitless", None), + (4, "Millimeters", "mm", Some(0.001)), + (5, "Centimeters", "cm", Some(0.01)), + (6, "Meters", "m", Some(1.0)), + (7, "Kilometers", "km", Some(1000.0)), + (1, "Inches", "in", Some(0.0254)), + (2, "Feet", "ft", Some(0.3048)), + (3, "Miles", "mi", Some(1609.344)), + (10, "Yards", "yd", Some(0.9144)), +]; + +/// Every unit on offer, in picker order: code and label. +pub fn all() -> impl Iterator { + UNITS.iter().map(|&(code, label, _, _)| (code, label)) +} + +/// The full name of a unit code. +pub fn label(code: i16) -> &'static str { + UNITS + .iter() + .find(|&&(candidate, ..)| candidate == code) + .map(|&(_, label, _, _)| label) + .unwrap_or("Unit") +} + +/// The short form shown on the status-bar pill. +pub fn short(code: i16) -> &'static str { + UNITS + .iter() + .find(|&&(candidate, ..)| candidate == code) + .map(|&(_, _, short, _)| short) + .unwrap_or("Unit") +} + +/// The code a typed name or abbreviation stands for. +pub fn code_for_keyword(name: &str) -> Option { + let name = name.trim(); + UNITS + .iter() + .find(|&&(_, label, short, _)| { + label.eq_ignore_ascii_case(name) || short.eq_ignore_ascii_case(name) + }) + .map(|&(code, ..)| code) +} + +/// What to multiply this drawing's lengths by to say the same distances in +/// `to`. +/// +/// `None` when either side is unitless: a drawing that does not say what its +/// numbers count cannot be converted, only relabelled. +pub fn conversion_factor(from: i16, to: i16) -> Option { + let metres = |code: i16| { + UNITS + .iter() + .find(|&&(candidate, ..)| candidate == code) + .and_then(|&(_, _, _, metres)| metres) + }; + let (from, to) = (metres(from)?, metres(to)?); + (to > 0.0).then(|| from / to) +} + +/// Handles of everything that lives in model space. +/// +/// Paper space is deliberately left out. Its geometry is measured on the sheet, +/// in sheet units, and means the same thing however the model is labelled — +/// scaling it would resize the drawing frame along with the building. +pub fn model_space_handles(scene: &crate::scene::Scene) -> Vec { + let Some(model) = scene.document.block_records.get("*Model_Space") else { + return Vec::new(); + }; + let owner = model.handle; + scene + .document + .entities() + .filter(|entity| entity.common().owner_handle == owner) + .map(|entity| entity.common().handle) + .collect() +} diff --git a/src/ui/popup/units_popup.rs b/src/ui/popup/units_popup.rs index fae8fd744..f9c4e6146 100644 --- a/src/ui/popup/units_popup.rs +++ b/src/ui/popup/units_popup.rs @@ -1,60 +1,87 @@ -//! Drawing-units status menu. +//! Linear-format status menu — the button that decides how every coordinate +//! and distance in the drawing is written. +//! +//! It offers LUNITS, not INSUNITS. Picking "Architectural" changes what the +//! readout, the properties panel and every dimension say; picking a *unit* +//! changes nothing anybody can see, which is why that choice lives in the UNITS +//! dialog next to the insertion scale it belongs to. (#668) -use iced::widget::{button, row, text}; -use iced::{Element, Fill}; +use iced::widget::{button, container, row, text, Space}; +use iced::{Background, Element, Fill, Theme}; use crate::app::Message; -use crate::ui::statusbar::status_menu::Entry; use crate::t; +use crate::ui::statusbar::status_menu::Entry; -/// Units offered in the picker: (INSUNITS code, menu label). -const UNITS: &[(i16, &str)] = &[ - (0, "Unitless"), - (4, "Millimeters"), - (5, "Centimeters"), - (6, "Meters"), - (7, "Kilometers"), - (1, "Inches"), - (2, "Feet"), - (3, "Miles"), - (10, "Yards"), -]; - -/// Short label shown on the status-bar pill for an INSUNITS code. -pub fn unit_short(code: i16) -> &'static str { - match code { - 1 => "in", - 2 => "ft", - 3 => "mi", - 4 => "mm", - 5 => "cm", - 6 => "m", - 7 => "km", - 10 => "yd", - 0 => "Unitless", - _ => "Unit", - } -} +use crate::modules::draw::units; pub fn menu_entries(current: i16) -> Vec> { - UNITS - .iter() - .map(|&(code, label)| { - Entry::close(unit_row( + let mut entries: Vec> = units::linear_formats() + .map(|(code, label, sample)| { + Entry::close(format_row( label, + sample, code == current, - Message::SetDrawingUnits(code), + Message::SetLinearFormat(code), )) }) - .collect() + .collect(); + + // Precision, angles and the insertion unit are all part of the same + // decision and all live one dialog away, so the way there is offered here + // rather than left to be found. + entries.push(Entry::stay(divider())); + entries.push(Entry::close(link( + t!("Units\u{2026}").into_owned(), + "UNITS", + ))); + entries.push(Entry::close(link( + t!("Convert drawing\u{2026}").into_owned(), + "DWGUNITS", + ))); + entries } -fn unit_row(label: &'static str, active: bool, msg: Message) -> Element<'static, Message> { - let check = crate::ui::icons::themed_check_cell(active); +fn divider() -> Element<'static, Message> { + container(Space::new().height(1)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .width(Fill) + .padding([0, 4]) + .into() +} - let lbl = text(t!(label)).size(11); +fn link(label: String, command: &'static str) -> Element<'static, Message> { + button(text(label).size(11)) + .on_press(Message::Command(command.into())) + .style(button::subtle) + .width(Fill) + .padding([4, 10]) + .into() +} - let content = row![check, lbl].spacing(6).align_y(iced::Center); +/// One format, shown with a sample of itself so the difference between +/// Engineering and Architectural is visible rather than recalled. +fn format_row( + label: &'static str, + sample: &'static str, + active: bool, + msg: Message, +) -> Element<'static, Message> { + let content = row![ + crate::ui::icons::themed_check_cell(active), + text(t!(label)).size(11), + Space::new().width(Fill), + text(sample).size(10).style(|theme: &Theme| { + iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.55)), + } + }), + ] + .spacing(6) + .align_y(iced::Center); button(content) .on_press(msg) diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index 5bb612e7d..be20635a4 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -102,8 +102,8 @@ impl StatusBar { picking: bool, // True while clean-screen mode hides the ribbon and side panels. clean_screen: bool, - // Drawing units (INSUNITS) for the units pill. - insertion_units: i16, + // LUNITS — how lengths are written, for the units pill. + linear_format: i16, // True when objects are hidden by Isolate / Hide. isolation_active: bool, // Whether entity transparency is shown (Transparency pill state). @@ -353,11 +353,13 @@ impl StatusBar { pills.push( status_menu::menu_bar( menu_tip( - popup_pill(t!(crate::ui::popup::units_popup::unit_short(insertion_units))), - t!("Drawing Units (INSUNITS)\nClick to change"), + popup_pill(t!(crate::modules::draw::units::linear_format_short( + linear_format + ))), + t!("Units (LUNITS)\nHow lengths are written\nClick to change"), tooltip_hidden, ), - crate::ui::popup::units_popup::menu_entries(insertion_units), + crate::ui::popup::units_popup::menu_entries(linear_format), 140.0, ) .into(), @@ -554,8 +556,15 @@ impl StatusBar { // ── Coordinate readout ──────────────────────────────────────────────────── +/// The readout, in the drawing's own linear units. +/// +/// Lengths went out at four decimal places whatever the drawing asked for, so a +/// drawing set to architectural units read its coordinates in decimals, and one +/// asking for two places got four. `format_length` is the same helper the +/// properties panel formats through, so both now say a length the same way. fn format_coords(cursor: glam::DVec3, last: Option, mode: i16, picking: bool) -> String { - let abs = |p: glam::DVec3| format!("{:.4}, {:.4}, {:.4}", p.x, p.y, p.z); + use crate::entities::common::format_length as len; + let abs = |p: glam::DVec3| format!("{}, {}, {}", len(p.x), len(p.y), len(p.z)); match mode { // Static: show the last picked point; the readout freezes between picks. 0 => abs(last.unwrap_or(cursor)), @@ -565,11 +574,14 @@ fn format_coords(cursor: glam::DVec3, last: Option, mode: i16, pick (true, Some(l)) => { let d = cursor - l; let dist = (d.x * d.x + d.y * d.y).sqrt(); - let mut ang = d.y.atan2(d.x).to_degrees(); - if ang < 0.0 { - ang += 360.0; - } - format!("{dist:.4} < {ang:.2}\u{b0}") + // Kept in 0..2pi so the readout never shows a negative bearing, + // then handed over in radians for AUNITS / AUPREC to format. + let ang = d.y.atan2(d.x).rem_euclid(std::f64::consts::TAU); + format!( + "{} < {}", + len(dist), + crate::entities::common::format_angle(ang) + ) } _ => abs(cursor), }, diff --git a/src/ui/window/drawing_units.rs b/src/ui/window/drawing_units.rs new file mode 100644 index 000000000..357a452a8 --- /dev/null +++ b/src/ui/window/drawing_units.rs @@ -0,0 +1,318 @@ +//! Drawing Units — how this drawing writes lengths and angles, and what one of +//! its units measures. +//! +//! Three separate settings that the word "units" runs together, kept in one +//! place because changing one usually means looking at the others. Length and +//! angle formats decide how numbers are *written*; the insertion unit decides +//! what they *count*, and only matters when content arrives from elsewhere. +//! Nothing here moves geometry — DWGUNITS does that. +//! +//! The sample updates as the fields change, so a format is chosen by seeing it +//! rather than by knowing what its name implies. (#668) + +use std::fmt; + +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::modules::draw::units; +use crate::t; + +/// The dialog's working copy. Nothing reaches the drawing until OK, so a format +/// can be tried against the sample and abandoned. +#[derive(Clone, PartialEq)] +pub struct State { + /// LUNITS / LUPREC. + pub linear_format: i16, + pub linear_precision: i16, + /// AUNITS / AUPREC. + pub angular_format: i16, + pub angular_precision: i16, + /// ANGDIR — positive angles run clockwise. + pub clockwise: bool, + /// ANGBASE — the direction angle zero points, in degrees. Held as text so + /// a half-typed number does not snap back while it is being typed. + pub base_angle: String, + /// INSUNITS. + pub insertion_units: i16, +} + +/// A dropdown entry that shows a translated name but hands back a raw code. +#[derive(Clone, PartialEq, Eq)] +pub struct Choice { + pub code: i16, + label: &'static str, + sample: &'static str, +} + +impl fmt::Display for Choice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = crate::i18n::translate(self.label); + if self.sample.is_empty() { + f.write_str(name.as_ref()) + } else { + write!(f, "{name} · {}", self.sample) + } + } +} + +/// Which field a dialog message is for. +#[derive(Clone, Debug)] +pub enum Field { + LinearFormat(i16), + LinearPrecision(i16), + AngularFormat(i16), + AngularPrecision(i16), + Clockwise(bool), + BaseAngle(String), + InsertionUnits(i16), +} + +fn muted(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.6)), + } +} + +fn group<'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 drop_row<'a>( + label: String, + options: Vec, + selected: Option, + ctor: fn(i16) -> Field, +) -> Element<'a, Message> { + let picker = iced::widget::pick_list(selected, options, |choice| choice.to_string()) + .on_select(move |choice| Message::DrawingUnitsField(ctor(choice.code))) + .text_size(12) + .padding([3, 6]) + .width(Fill); + row![text(label).size(11).style(muted).width(78), picker] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// Precision is 0–8 places everywhere it applies; fractional and architectural +/// read the same number as a denominator power, which is why it is offered as +/// a plain count rather than as decimals. +fn precision_row<'a>( + current: i16, + fractional: bool, + ctor: fn(i16) -> Field, +) -> Element<'a, Message> { + let options: Vec = (0..=8) + .map(|n| Choice { + code: n, + label: PRECISION_LABELS[n as usize], + sample: if fractional { + FRACTION_SAMPLES[n as usize] + } else { + "" + }, + }) + .collect(); + let selected = options + .iter() + .find(|choice| choice.code == current.clamp(0, 8)) + .cloned(); + drop_row(t!("Precision").into_owned(), options, selected, ctor) +} + +const PRECISION_LABELS: [&str; 9] = ["0", "1", "2", "3", "4", "5", "6", "7", "8"]; +const FRACTION_SAMPLES: [&str; 9] = [ + "1", "1/2", "1/4", "1/8", "1/16", "1/32", "1/64", "1/128", "1/256", +]; + +pub fn view_window<'a>( + state: &State, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let linear_options: Vec = units::linear_formats() + .map(|(code, label, sample)| Choice { + code, + label, + sample, + }) + .collect(); + let linear_selected = linear_options + .iter() + .find(|choice| choice.code == state.linear_format) + .cloned(); + // Architectural and fractional read precision as a fraction denominator, + // so the list shows fractions instead of decimal places for those two. + let fractional = matches!(state.linear_format, 4 | 5); + + let angular_options: Vec = units::angular_formats() + .map(|(code, label, sample)| Choice { + code, + label, + sample, + }) + .collect(); + let angular_selected = angular_options + .iter() + .find(|choice| choice.code == state.angular_format) + .cloned(); + + let insertion_options: Vec = units::all() + .map(|(code, label)| Choice { + code, + label, + sample: "", + }) + .collect(); + let insertion_selected = insertion_options + .iter() + .find(|choice| choice.code == state.insertion_units) + .cloned(); + + let length = group( + t!("Length").into_owned(), + column![ + drop_row( + t!("Type").into_owned(), + linear_options, + linear_selected, + Field::LinearFormat + ), + precision_row(state.linear_precision, fractional, Field::LinearPrecision), + ] + .spacing(6) + .into(), + ); + + let angle = group( + t!("Angle").into_owned(), + column![ + drop_row( + t!("Type").into_owned(), + angular_options, + angular_selected, + Field::AngularFormat + ), + precision_row(state.angular_precision, false, Field::AngularPrecision), + row![ + text(t!("Zero at")).size(11).style(muted).width(78), + text_input("0", &state.base_angle) + .on_input(|value| Message::DrawingUnitsField(Field::BaseAngle(value))) + .size(12) + .padding([3, 6]) + .width(Length::Fixed(80.0)), + text(t!("degrees")).size(11).style(muted), + ] + .spacing(8) + .align_y(iced::Center), + row![ + Space::new().width(Length::Fixed(78.0)), + checkbox(state.clockwise) + .on_toggle(|on| Message::DrawingUnitsField(Field::Clockwise(on))) + .size(14), + text(t!("Positive angles run clockwise")).size(11), + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(6) + .into(), + ); + + // What the insertion unit is for, said once here, because the setting is + // invisible until content arrives from another drawing and is otherwise + // easy to mistake for the one above it. + let insertion = group( + t!("Insertion scale").into_owned(), + column![ + drop_row( + t!("Unit").into_owned(), + insertion_options, + insertion_selected, + Field::InsertionUnits + ), + text(t!( + "Scales blocks and drawings inserted from elsewhere. Unitless inserts them unscaled." + )) + .size(10) + .style(muted), + ] + .spacing(6) + .into(), + ); + + let sample = group( + t!("Sample output").into_owned(), + column![ + text(sample_length(state)).size(12), + text(sample_angle(state)).size(12), + ] + .spacing(3) + .into(), + ); + + let actions = row![ + Space::new().width(Fill), + button(text(t!("Cancel")).size(12)) + .on_press(Message::CloseModal) + .padding([4, 12]), + button(text(t!("OK")).size(12)) + .on_press(Message::DrawingUnitsApply) + .style(button::primary) + .padding([4, 12]), + ] + .spacing(6) + .align_y(iced::Center); + + column![row![length, angle].spacing(8), insertion, sample, actions,] + .spacing(8) + .padding(10) + .width(sizing.width) + .into() +} + +/// The sample is produced by the same formatters the drawing uses, so what it +/// shows is what the readout will show — not a second implementation that can +/// drift from the first. +fn sample_length(state: &State) -> String { + with_context(state, || { + crate::entities::common::format_length(1.5) + + " " + + &crate::entities::common::format_length(33.5) + }) +} + +fn sample_angle(state: &State) -> String { + with_context(state, || { + crate::entities::common::format_angle(std::f64::consts::FRAC_PI_4) + }) +} + +/// Format under the dialog's pending settings, then put back whatever the rest +/// of the frame is drawing with. Without the restore the sample would leak its +/// settings into every widget drawn after it. +fn with_context(state: &State, body: impl FnOnce() -> String) -> String { + use crate::entities::common::{set_unit_context, unit_context, UnitContext}; + let previous = unit_context(); + set_unit_context(UnitContext { + lunits: state.linear_format, + luprec: state.linear_precision, + aunits: state.angular_format, + auprec: state.angular_precision, + }); + let out = body(); + set_unit_context(previous); + out +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 41d84ebb6..4e8cd139d 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -2,6 +2,7 @@ pub mod about; pub mod block_palette; pub mod layout_manager; pub mod layer_state_manager; +pub mod drawing_units; pub mod layer_translator; pub mod plot; pub mod print_all;