From 7d33d9d4dd6ad0c339c0cdf2d66a127da7fc6485 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:50:19 -0400 Subject: [PATCH] refactor: split complex input, explode, sweep, cache and options code into helpers (#2187) Pure extraction, no behaviour change: same order of side effects, same early returns and error values. Clears cognitive-complexity findings on AreaM::input, explode_kind, hatch sweep, the COMPARE overlay paint and the Files options tab (S3776). --- crates/engine/src/cmd/inquiry.rs | 97 +++++----- crates/engine/src/cmd/modify.rs | 257 +++++++++++++-------------- crates/render/src/hatch.rs | 64 ++++--- crates/ui-egui/src/canvas/compare.rs | 32 ++-- crates/ui-egui/src/options/dialog.rs | 138 +++++++------- 5 files changed, 313 insertions(+), 275 deletions(-) diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index f1bfd8073..4d82d5c5c 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -963,6 +963,57 @@ impl AreaM { AreaMode::Subtract => self.total - v, } } + /// Input while a height is pending: typed, or given by two points (their distance). + fn height_input(&mut self, s: &mut Session, i: Input) -> Step { + match (i, self.height_from) { + (Input::Point(p), None) => self.height_from = Some(p), + (Input::Point(p), Some(from)) => { + self.height_from = None; + return self.height(s, p.dist(from)); + } + (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { + Some(h) => return self.height(s, h), + None => s.echo("Requires a distance."), + }, + (Input::Enter, None) => s.echo("Requires a height."), + (Input::Enter, Some(_)) => s.echo("Requires a second point."), + (Input::Text(_), Some(_)) => s.echo("Requires a point."), + _ => {} + } + Step::Continue + } + + /// Input while the objects to measure are picked. + fn object_input(&mut self, s: &mut Session, i: Input) -> Step { + match i { + Input::Pick(hs) => { + for h in hs { + match object_area(s, h) { + Ok((a, per)) => { + if self.measured(s, a, per) == Step::Done { + return Step::Done; + } + // A single volume takes the first object with an area. + if self.volume && self.mode == AreaMode::Single { + break; + } + } + Err(_) => s.echo("The selected object has no area."), + } + } + } + Input::Enter => { + self.object = false; + if self.mode == AreaMode::Single { + return Step::Done; + } + } + Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"), + _ => {} + } + Step::Continue + } + /// Measure the oldest pending area's volume with height `h`. fn height(&mut self, s: &mut Session, h: f64) -> Step { match self.pending.first().map(|a| a * h.abs()) { @@ -1042,52 +1093,10 @@ impl Interactive for AreaM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { if !self.pending.is_empty() { - // A height is typed, or given by two points (their distance). - match (i, self.height_from) { - (Input::Point(p), None) => self.height_from = Some(p), - (Input::Point(p), Some(from)) => { - self.height_from = None; - return Ok(self.height(s, p.dist(from))); - } - (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { - Some(h) => return Ok(self.height(s, h)), - None => s.echo("Requires a distance."), - }, - (Input::Enter, None) => s.echo("Requires a height."), - (Input::Enter, Some(_)) => s.echo("Requires a second point."), - (Input::Text(_), Some(_)) => s.echo("Requires a point."), - _ => {} - } - return Ok(Step::Continue); + return Ok(self.height_input(s, i)); } if self.object { - match i { - Input::Pick(hs) => { - for h in hs { - match object_area(s, h) { - Ok((a, per)) => { - if self.measured(s, a, per) == Step::Done { - return Ok(Step::Done); - } - // A single volume takes the first object with an area. - if self.volume && self.mode == AreaMode::Single { - break; - } - } - Err(_) => s.echo("The selected object has no area."), - } - } - } - Input::Enter => { - self.object = false; - if self.mode == AreaMode::Single { - return Ok(Step::Done); - } - } - Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"), - _ => {} - } - return Ok(Step::Continue); + return Ok(self.object_input(s, i)); } if self.length { if let Some(p) = length_input(&self.pts, i)? { diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index c25eb50c8..f2116e9d0 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1296,6 +1296,128 @@ fn run_chamfer(s: &mut Session, p: &Value) -> Result { // ---------------- explode / array / order / break / join ---------------- +fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option> { + if let Some(items) = super::array::explode_array(d, e) { + return Some(items); + } + let blk = d.block(&ins.block)?; + let m = ins.transform(blk.base.xy()); + Some( + blk.entities + .iter() + .map(|be| { + let mut ne = (**be).clone(); + ne.kind.transform(&m); + if ne.common.layer == "0" { + ne.common.layer = e.common.layer.clone(); + } + if ne.common.color == cadcraft_color::Color::ByBlock { + ne.common.color = e.common.color; + } + ne + }) + .collect(), + ) +} + +fn explode_mtext(e: &Entity, m: &cadcraft_doc::MText) -> Vec { + cadcraft_fonts::plain_mtext(&m.contents) + .split('\n') + .enumerate() + .map(|(i, l)| { + let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64)); + Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::Text(cadcraft_doc::Text { + insert: v3(at), + align_pt: None, + height: m.height, + value: l.into(), + rotation: m.rotation, + width_factor: 1.0, + oblique: 0.0, + style: m.style.clone(), + halign: Default::default(), + valign: Default::default(), + }), + } + }) + .collect() +} + +/// The entities of a boundary's lines and arcs, on the object's layer and style. +fn segment_entities(e: &Entity, segs: impl IntoIterator) -> Vec { + segs.into_iter() + .map(|s| { + let k = match s { + Segment::Line(l) => line(l.a, l.b), + Segment::Arc { arc: a, .. } => arc(&a), + }; + Entity { handle: Handle(0), common: e.common.clone(), kind: k } + }) + .collect() +} + +fn explode_dimension(d: &cadcraft_doc::Drawing, e: &Entity, dm: &cadcraft_doc::Dimension) -> Vec { + // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. + let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = cadcraft_render::dimension_in(d, dm); + let line_entity = |l: &Vec| { + if l.len() == 2 { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) + } else { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) }) + } + }; + // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. + let text_marks = &g.text_marks; + let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l); + let mut v: Vec = marks(false).filter_map(&line_entity).collect(); + for t in &g.fills { + if let [a, b, c] = t.as_slice() { + v.push(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }), + }); + } + } + // DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and + // arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3). + if style.text_fill == 2 + && let [p0, p1, p2, p3] = g.text_fill.as_slice() + { + let fill = style.text_fill_color; + let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill }; + v.push(Entity { + handle: Handle(0), + common: cadcraft_doc::Common { color, ..e.common.clone() }, + kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }), + }); + } + v.extend(marks(true).filter_map(&line_entity)); + if !g.value.is_empty() { + v.push(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::MText(cadcraft_doc::MText { + insert: v3(g.text_pos), + height: g.text_height, + width: 0.0, + attach: 5, + rotation: g.text_angle, + style: style.text_style.clone(), + contents: g.mtext_contents(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }), + }); + } + v +} + pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option> { // An annotative object explodes into plain objects as it draws at the current annotation scale. if cadcraft_doc::is_annotative(e) { @@ -1309,42 +1431,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - Some( - pl.segments() - .into_iter() - .map(|s| { - let k = match s { - Segment::Line(l) => line(l.a, l.b), - Segment::Arc { arc: a, .. } => arc(&a), - }; - Entity { handle: Handle(0), common: e.common.clone(), kind: k } - }) - .collect(), - ) - } - EntityKind::Insert(ins) => { - if let Some(items) = super::array::explode_array(d, e) { - return Some(items); - } - let blk = d.block(&ins.block)?; - let m = ins.transform(blk.base.xy()); - Some( - blk.entities - .iter() - .map(|be| { - let mut ne = (**be).clone(); - ne.kind.transform(&m); - if ne.common.layer == "0" { - ne.common.layer = e.common.layer.clone(); - } - if ne.common.color == cadcraft_color::Color::ByBlock { - ne.common.color = e.common.color; - } - ne - }) - .collect(), - ) + Some(segment_entities(e, pl.segments())) } + EntityKind::Insert(ins) => explode_block_ref(d, e, ins), // A helix explodes into its spline (seen from above). EntityKind::Helix(h) => Some(vec![Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Spline(h.spline()) }]), EntityKind::Polyline3d(p) => Some( @@ -1357,102 +1446,10 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option Some(super::draw2::mline::explode(e, m)), - EntityKind::MText(m) => Some( - cadcraft_fonts::plain_mtext(&m.contents) - .split('\n') - .enumerate() - .map(|(i, l)| { - let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64)); - Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::Text(cadcraft_doc::Text { - insert: v3(at), - align_pt: None, - height: m.height, - value: l.into(), - rotation: m.rotation, - width_factor: 1.0, - oblique: 0.0, - style: m.style.clone(), - halign: Default::default(), - valign: Default::default(), - }), - } - }) - .collect(), - ), + EntityKind::MText(m) => Some(explode_mtext(e, m)), // A region explodes into its boundary's lines and arcs. - EntityKind::Region(r) => Some( - r.segments() - .into_iter() - .map(|s| { - let k = match s { - Segment::Line(l) => line(l.a, l.b), - Segment::Arc { arc: a, .. } => arc(&a), - }; - Entity { handle: Handle(0), common: e.common.clone(), kind: k } - }) - .collect(), - ), - EntityKind::Dimension(dm) => { - // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. - let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); - let g = cadcraft_render::dimension_in(d, dm); - let line_entity = |l: &Vec| { - if l.len() == 2 { - Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) - } else { - Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) }) - } - }; - // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. - let text_marks = &g.text_marks; - let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l); - let mut v: Vec = marks(false).filter_map(&line_entity).collect(); - for t in &g.fills { - if let [a, b, c] = t.as_slice() { - v.push(Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }), - }); - } - } - // DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and - // arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3). - if style.text_fill == 2 - && let [p0, p1, p2, p3] = g.text_fill.as_slice() - { - let fill = style.text_fill_color; - let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill }; - v.push(Entity { - handle: Handle(0), - common: cadcraft_doc::Common { color, ..e.common.clone() }, - kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }), - }); - } - v.extend(marks(true).filter_map(&line_entity)); - if !g.value.is_empty() { - v.push(Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::MText(cadcraft_doc::MText { - insert: v3(g.text_pos), - height: g.text_height, - width: 0.0, - attach: 5, - rotation: g.text_angle, - style: style.text_style.clone(), - contents: g.mtext_contents(), - line_spacing: 1.0, - line_spacing_exact: false, - columns: None, - }), - }); - } - Some(v) - } + EntityKind::Region(r) => Some(segment_entities(e, r.segments())), + EntityKind::Dimension(dm) => Some(explode_dimension(d, e, dm)), EntityKind::Tolerance(t) => Some(super::tolerance::explode(d, e, t)), _ => None, } diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index f48b6344e..1bfeb1b6c 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -465,6 +465,26 @@ impl Family<'_> { self.origin + p.rotate(self.ang) } + /// The range of the lines swept, in this frame: the boundary's extent, cut to the view's + /// when there is one, and the view's x extent that spans are cut to. Spans are cut to the + /// view's extent only: they already lie within the boundary, and cutting them to its extent + /// too would move an end that sits on a boundary corner by a rounding error, so a view + /// covering the hatch would change its lines. `None` when the view misses the boundary. + fn lines_range(&self, bb: &Bounds2, view: Option<&Bounds2>) -> Option<(Vec2, Vec2, Option<(f64, f64)>)> { + let (mut lo, mut hi) = (bb.min, bb.max); + let mut span_cut = None; + if let Some(v) = view { + let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p))); + if vb.is_empty() || !vb.intersects(bb) { + return None; + } + lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y)); + hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y)); + span_cut = Some((vb.min.x, vb.max.x)); + } + Some((lo, hi, span_cut)) + } + /// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits. fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>, work: &mut u64) { let local: Vec> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect(); @@ -472,21 +492,7 @@ impl Family<'_> { if bb.is_empty() { return; } - // The lines swept: the boundary's extent, cut to the view's (its corners in this frame) - // when there is one. Spans are cut to the view's extent only: they already lie within the - // boundary, and cutting them to its extent too would move an end that sits on a boundary - // corner by a rounding error, so a view covering the hatch would change its lines. - let (mut lo, mut hi) = (bb.min, bb.max); - let mut span_cut = None; - if let Some(v) = view { - let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p))); - if vb.is_empty() || !vb.intersects(&bb) { - return; - } - lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y)); - hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y)); - span_cut = Some((vb.min.x, vb.max.x)); - } + let Some((lo, hi, span_cut)) = self.lines_range(&bb, view) else { return }; let shift_per_line = self.delta.rotate(-self.ang).x; let k0 = (lo.y / self.spacing).floor() as i64; let k1 = (hi.y / self.spacing).ceil() as i64; @@ -504,17 +510,7 @@ impl Family<'_> { return; } let y = k as f64 * self.spacing; - let mut xs: Vec = Vec::new(); - for l in &local { - let n = l.len(); - for i in 0..n { - let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; - if (a.y > y) != (b.y > y) { - xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y)); - } - } - } - xs.sort_by(f64::total_cmp); + let xs = crossings(&local, y); let offset = shift_per_line * k as f64; for span in xs.chunks(2) { let [x0, x1] = span else { continue }; @@ -565,6 +561,22 @@ impl Family<'_> { } } +/// The x positions where the boundary loops cross the horizontal line at `y`, in order. +fn crossings(local: &[Vec], y: f64) -> Vec { + let mut xs: Vec = Vec::new(); + for l in local { + let n = l.len(); + for i in 0..n { + let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; + if (a.y > y) != (b.y > y) { + xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y)); + } + } + } + xs.sort_by(f64::total_cmp); + xs +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/canvas/compare.rs b/crates/ui-egui/src/canvas/compare.rs index c545a1d28..32679e554 100644 --- a/crates/ui-egui/src/canvas/compare.rs +++ b/crates/ui-egui/src/canvas/compare.rs @@ -4,7 +4,8 @@ use std::sync::Arc; -use cadcraft_engine::doc::Handle; +use cadcraft_engine::cmd::file::compare::Comparison; +use cadcraft_engine::doc::{Drawing, Handle}; use cadcraft_render::DisplayList; use egui::{Rect, RichText, Stroke, vec2}; use serde_json::json; @@ -29,6 +30,23 @@ fn id() -> egui::Id { egui::Id::unique("cadcraft-compare-overlay") } +/// The display lists of the differences for this frame: the stored ones when they still match +/// `key`, otherwise rebuilt at zoom `scale` and stored. +fn cache_for(ctx: &egui::Context, key: (u64, u64, usize, usize, i64), doc: &Drawing, c: &Comparison, scale: f64) -> Cache { + let mut cache: Cache = ctx.data_mut(|d| d.get_temp(id())).unwrap_or_default(); + if cache.key != key || cache.ours.is_none() { + let opts = cadcraft_render::Options { tolerance: 0.5 / scale.max(1e-12), ..Default::default() }; + let ours: Vec = c.added.iter().copied().chain(c.changed.iter().map(|(a, _)| *a)).take(MAX_DRAWN).collect(); + let theirs: Vec = c.removed.iter().copied().chain(c.changed.iter().map(|(_, b)| *b)).take(MAX_DRAWN).collect(); + let list = |d: &Drawing, hs: &[Handle]| { + Arc::new(cadcraft_render::build_entities(d, hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.as_ref()), &opts)) + }; + cache = Cache { key, ours: Some(list(doc, &ours)), theirs: Some(list(&c.other, &theirs)) }; + ctx.data_mut(|d| d.insert_temp(id(), cache.clone())); + } + cache +} + /// Draw the comparison over the canvas when it was made for the drawing and tab shown. pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf: &Xf, rect: Rect) { let t = Tokens::get(); @@ -38,17 +56,7 @@ pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf p.rect_filled(rect, 0.0, t.canvas.gamma_multiply(0.75)); let band = xf.scale.max(1e-300).log2().round() as i64; let key = (st.uid, st.revision, Arc::as_ptr(&c.other) as usize, c.differences(), band); - let mut cache: Cache = ctx.data_mut(|d| d.get_temp(id())).unwrap_or_default(); - if cache.key != key || cache.ours.is_none() { - let opts = cadcraft_render::Options { tolerance: 0.5 / xf.scale.max(1e-12), ..Default::default() }; - let ours: Vec = c.added.iter().copied().chain(c.changed.iter().map(|(a, _)| *a)).take(MAX_DRAWN).collect(); - let theirs: Vec = c.removed.iter().copied().chain(c.changed.iter().map(|(_, b)| *b)).take(MAX_DRAWN).collect(); - let list = |d: &cadcraft_engine::doc::Drawing, hs: &[Handle]| { - Arc::new(cadcraft_render::build_entities(d, hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.as_ref()), &opts)) - }; - cache = Cache { key, ours: Some(list(&st.doc, &ours)), theirs: Some(list(&c.other, &theirs)) }; - ctx.data_mut(|d| d.insert_temp(id(), cache.clone())); - } + let cache = cache_for(ctx, key, &st.doc, c, xf.scale); for (list, color) in [(&cache.theirs, t.compare_other), (&cache.ours, t.compare_this)] { if let Some(l) = list { let all: Vec = (0..l.prims.len()).collect(); diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index cfc1833f0..0a514720e 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -217,73 +217,15 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { /// Files tab: save format and default version, new-drawing units, autosave interval and autosave file location; edits accumulate in `changes`. fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_files", |ui| { - ui.label(crate::tl!("Save new drawings as")); - ui.horizontal(|ui| { - for f in crate::SaveFormat::ALL { - let label = match f { - crate::SaveFormat::Dxf => crate::tl!("DXF"), - crate::SaveFormat::Dwg => crate::tl!("DWG"), - }; - if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f { - changes.insert("saveFormat".into(), json!(f.as_str())); - } - } - }); + save_format_row(ui, app, changes); ui.end_row(); - - let version_caption = ui.label(crate::tl!("Default save version")); - let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); - let mut version = current; - let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { - for v in crate::SaveVersion::ALL { - ui.selectable_value(&mut version, v, v.year()); - } - }); - version_combo.response.labelled_by(version_caption.id); - if version != current { - changes.insert("saveVersion".into(), json!(version.year())); - } + save_version_row(ui, app, changes); ui.end_row(); - - ui.label(crate::tl!("Units of new drawings")); - ui.horizontal(|ui| { - let metric = app.session.settings.measureinit; - if ui.radio(!metric, crate::tl!("Imperial (inches)")).clicked() && metric { - changes.insert("MEASUREINIT".into(), json!(0)); - } - if ui.radio(metric, crate::tl!("Metric (millimetres)")).clicked() && !metric { - changes.insert("MEASUREINIT".into(), json!(1)); - } - }); + units_row(ui, app, changes); ui.end_row(); - - let minutes_caption = ui.label(crate::tl!("Automatic save every")); - let mut minutes = cadcraft_engine::autosave::savetime(); - let max = cadcraft_engine::autosave::MAX_SAVETIME; - if ui - .add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))) - .labelled_by(minutes_caption.id) - .changed() - { - changes.insert("SAVETIME".into(), json!(minutes)); - } + autosave_row(ui, changes); ui.end_row(); - - let savefile_label = ui.label(crate::tl!("Automatic save file location")); - // Edited as text; the folder changes when the field loses focus. - let id = egui::Id::unique("cc_options_savefilepath"); - let current = cadcraft_engine::autosave::savefilepath(); - let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id); - if r.changed() { - ui.data_mut(|d| d.insert_temp(id, path.clone())); - } - if r.lost_focus() { - ui.data_mut(|d| d.remove::(id)); - if path.trim() != current { - changes.insert("SAVEFILEPATH".into(), json!(path.trim())); - } - } + savefile_row(ui, changes); ui.end_row(); }); hint(ui, crate::tl!("New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE).")); @@ -291,6 +233,76 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { hint(ui, crate::tl!("Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off.")); } +fn save_format_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + ui.label(crate::tl!("Save new drawings as")); + ui.horizontal(|ui| { + for f in crate::SaveFormat::ALL { + let label = match f { + crate::SaveFormat::Dxf => crate::tl!("DXF"), + crate::SaveFormat::Dwg => crate::tl!("DWG"), + }; + if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f { + changes.insert("saveFormat".into(), json!(f.as_str())); + } + } + }); +} + +fn save_version_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + let version_caption = ui.label(crate::tl!("Default save version")); + let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); + let mut version = current; + let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { + for v in crate::SaveVersion::ALL { + ui.selectable_value(&mut version, v, v.year()); + } + }); + version_combo.response.labelled_by(version_caption.id); + if version != current { + changes.insert("saveVersion".into(), json!(version.year())); + } +} + +fn units_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + ui.label(crate::tl!("Units of new drawings")); + ui.horizontal(|ui| { + let metric = app.session.settings.measureinit; + if ui.radio(!metric, crate::tl!("Imperial (inches)")).clicked() && metric { + changes.insert("MEASUREINIT".into(), json!(0)); + } + if ui.radio(metric, crate::tl!("Metric (millimetres)")).clicked() && !metric { + changes.insert("MEASUREINIT".into(), json!(1)); + } + }); +} + +fn autosave_row(ui: &mut egui::Ui, changes: &mut Map) { + let minutes_caption = ui.label(crate::tl!("Automatic save every")); + let mut minutes = cadcraft_engine::autosave::savetime(); + let max = cadcraft_engine::autosave::MAX_SAVETIME; + if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).labelled_by(minutes_caption.id).changed() { + changes.insert("SAVETIME".into(), json!(minutes)); + } +} + +fn savefile_row(ui: &mut egui::Ui, changes: &mut Map) { + let savefile_label = ui.label(crate::tl!("Automatic save file location")); + // Edited as text; the folder changes when the field loses focus. + let id = egui::Id::unique("cc_options_savefilepath"); + let current = cadcraft_engine::autosave::savefilepath(); + let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id); + if r.changed() { + ui.data_mut(|d| d.insert_temp(id, path.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(id)); + if path.trim() != current { + changes.insert("SAVEFILEPATH".into(), json!(path.trim())); + } + } +} + /// Drafting tab: object-snap marker size and aperture previews plus the Drafting Settings entry; edits accumulate in `changes` and the entry may request a command through `start`. fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, start: &mut Option<&'static str>) { let t = Tokens::get();