From 8efaece5cb20ca370c3ea3dc66a3f6d7a22da573 Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 30 Sep 2026 14:39:20 -0300 Subject: [PATCH 01/13] fix(osnap): detect polyline self-intersections --- src/snap.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/src/snap.rs b/src/snap.rs index d29883ae2..b27106002 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1755,6 +1755,53 @@ impl Snapper { if self.is_on(SnapType::Intersection) && (local_segments.is_some() || allow_unindexed_pairwise) { + // Unindexed fallback: also test non-adjacent segments belonging to the + // same wire. A polyline is one entity but its own segments may cross. + if local_segments.is_none() { + for wire in in_range_wires.iter() { + let src = wire_source(wire); + let segment_count = wire.points.len().saturating_sub(1); + + for ai in 0..segment_count { + let a0 = wp_f64(wire, ai); + let a1 = wp_f64(wire, ai + 1); + + let a_min_x = a0.x.min(a1.x); + let a_max_x = a0.x.max(a1.x); + let a_min_y = a0.y.min(a1.y); + let a_max_y = a0.y.max(a1.y); + + for bi in (ai + 1)..segment_count { + let b0 = wp_f64(wire, bi); + let b1 = wp_f64(wire, bi + 1); + + // Consecutive polyline segments meet at a normal vertex. + // That is already an Endpoint snap, not a self-intersection. + if segments_share_endpoint(a0, a1, b0, b1) { + continue; + } + + // Cheap AABB rejection before the exact 3-D intersection. + if a_max_x < b0.x.min(b1.x) + || a_min_x > b0.x.max(b1.x) + || a_max_y < b0.y.min(b1.y) + || a_min_y > b0.y.max(b1.y) + { + continue; + } + + if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { + try_pt( + pt, + SnapType::Intersection, + src, + None, + ); + } + } + } + } + } if let Some(segments) = &local_segments { let local_wires: Vec<_> = wires.iter().filter(|wire| wire_in_range(wire)).collect(); let mut resolved_pairs = rustc_hash::FxHashSet::default(); @@ -1789,7 +1836,13 @@ impl Snapper { if b.min_x() > a.max_x() { break; } - if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() { + let same_wire_neighbours = a.wire == b.wire + && segments_share_endpoint(a.a, a.b, b.a, b.b); + + if same_wire_neighbours + || a.max_y() < b.min_y() + || a.min_y() > b.max_y() + { continue; } if wires.source_wire(a.wire).zip(wires.source_wire(b.wire)) @@ -1801,11 +1854,23 @@ impl Snapper { world_to_screen(pt, view_rot, eye, bounds), cursor_screen, ) <= f32::EPSILON; + let primary = wires + .source_wire(a.wire) + .and_then(wire_source); + + let secondary = if a.wire == b.wire { + None + } else { + wires + .source_wire(b.wire) + .and_then(wire_source) + }; + try_pt( pt, SnapType::Intersection, - wires.source_wire(a.wire).and_then(wire_source), - wires.source_wire(b.wire).and_then(wire_source), + primary, + secondary, ); if exact_cursor { break 'intersection_sweep; @@ -3099,6 +3164,24 @@ pub(crate) fn exact_curve_intersections( Some(points) } +/// Two segments of the same polyline that merely meet at a shared endpoint +/// are neighbours, not a self-intersection. +/// +/// Non-adjacent segments are allowed to intersect even when they belong to +/// the same WireModel. +fn segments_share_endpoint( + a0: DVec3, + a1: DVec3, + b0: DVec3, + b1: DVec3, +) -> bool { + const EPS2: f64 = 1.0e-18; + + a0.distance_squared(b0) <= EPS2 + || a0.distance_squared(b1) <= EPS2 + || a1.distance_squared(b0) <= EPS2 + || a1.distance_squared(b1) <= EPS2 +} /// XY-plane segment-segment intersection. Returns `None` if parallel or outside. /// True 3D intersection of two segments: the point where their plan (XY) /// projections cross **and** both segments are at the same height there. Returns From 2131f4390f379a31f6d1dd0512a53d5ebe3fb175 Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 30 Sep 2026 15:35:51 -0300 Subject: [PATCH 02/13] fix(trim): support polyline self-intersections and box trimming --- src/modules/draw/modify/trim.rs | 279 +++++++++++++++++++++++++++----- 1 file changed, 236 insertions(+), 43 deletions(-) diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index e48082364..ecf25bae5 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -1108,6 +1108,161 @@ fn extract_sub_polyline(poly: &LwPolyline, s0: f64, s1: f64) -> Option Option<(Curve, bool)> { + let n = poly.vertices.len(); + if n < 2 { + return None; + } + + let a = &poly.vertices[segment % n]; + let b = &poly.vertices[(segment + 1) % n]; + + let p0 = [a.location.x, a.location.y]; + let p1 = [b.location.x, b.location.y]; + + if (p1[0] - p0[0]).hypot(p1[1] - p0[1]) < 1e-12 { + return None; + } + + if let Some(arc) = BulgeArc::from_bulge(p0, p1, a.bulge) { + if arc.sweep >= 0.0 { + Some(( + Curve::Arc(KernelArc { + centre: arc.center, + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.start_angle + arc.sweep, + }), + false, + )) + } else { + Some(( + Curve::Arc(KernelArc { + centre: arc.center, + radius: arc.radius, + start_angle: arc.end_angle, + end_angle: arc.end_angle - arc.sweep, + }), + true, + )) + } + } else { + Some(( + Curve::Line(KernelLine { + start: p0, + end: p1, + }), + false, + )) + } +} + +/// Global polyline parameters at which non-adjacent segments of the same +/// polyline intersect. +/// +/// Segment neighbours are deliberately excluded: their common vertex is a +/// normal polyline vertex, not a self-intersection. +fn lwpoly_self_cut_params(poly: &LwPolyline) -> Vec { + let n = poly.vertices.len(); + if n < 3 { + return Vec::new(); + } + + let closed = poly.is_closed; + let segment_count = if closed { n } else { n - 1 }; + let total = segment_count as f64; + + let mut cuts = Vec::new(); + + for i in 0..segment_count { + let Some((curve_a, reverse_a)) = lwpoly_segment_curve(poly, i) else { + continue; + }; + + for j in (i + 1)..segment_count { + // Ordinary neighbouring segments meet at their common vertex. + let adjacent = j == i + 1 + || (closed && i == 0 && j + 1 == segment_count); + + if adjacent { + continue; + } + + let Some((curve_b, reverse_b)) = lwpoly_segment_curve(poly, j) else { + continue; + }; + + let hits = kernel_intersect( + &curve_a, + &curve_b, + KernelTolerance::new(CUT_TOLERANCE), + ); + + for hit in hits { + let mut ua = hit.t_a; + let mut ub = hit.t_b; + + if reverse_a { + ua = 1.0 - ua; + } + if reverse_b { + ub = 1.0 - ub; + } + + if !ua.is_finite() + || !ub.is_finite() + || ua < -1e-7 + || ua > 1.0 + 1e-7 + || ub < -1e-7 + || ub > 1.0 + 1e-7 + { + continue; + } + + ua = ua.clamp(0.0, 1.0); + ub = ub.clamp(0.0, 1.0); + + // If both sides only touch at existing vertices, those + // vertices already act as trim limits below. + let a_endpoint = ua <= 1e-7 || ua >= 1.0 - 1e-7; + let b_endpoint = ub <= 1e-7 || ub >= 1.0 - 1e-7; + + if a_endpoint && b_endpoint { + continue; + } + + let pa = i as f64 + ua; + let pb = j as f64 + ub; + + cuts.push(if closed { + pa.rem_euclid(total) + } else { + pa + }); + + cuts.push(if closed { + pb.rem_euclid(total) + } else { + pb + }); + } + } + } + + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + cuts +} + /// Trim a clicked LwPolyline: remove the portion containing the click, bounded /// by the nearest boundary intersections on each side. A closed polyline needs /// ≥2 cuts and becomes an open polyline (the surviving arc); an open one yields @@ -1132,22 +1287,46 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< }; // Boundary cuts as global params (segment index + local u). + // External boundary cuts as global params (segment index + local u). let mut cuts: Vec = Vec::new(); + for i in 0..seg_count { let p0 = vx(i); let p1 = vx(i + 1); let b = seg_bulge(i); + for u in polyline_seg_ts(p0, p1, b, handle, geos) { let param = i as f64 + u.clamp(0.0, 1.0); - cuts.push(if closed { param.rem_euclid(total) } else { param }); + + cuts.push(if closed { + param.rem_euclid(total) + } else { + param + }); } } - cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - if cuts.is_empty() { - return None; + + // A polyline can also cut itself. These are intersections between + // non-adjacent segments belonging to this same LwPolyline. + cuts.extend(lwpoly_self_cut_params(poly)); + + // Every polyline vertex is also a valid trim boundary. This allows an entire + // segment between two vertices to be removed even when no external entity + // crosses that particular segment. + if closed { + for i in 0..seg_count { + cuts.push(i as f64); + } + } else { + // 0 and `total` are added explicitly below as the open polyline ends. + for i in 1..seg_count { + cuts.push(i as f64); + } } + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + // Click param: nearest point on the polyline. let mut best = (f64::INFINITY, 0.0_f64); for i in 0..seg_count { @@ -1251,11 +1430,7 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< } } - if out.is_empty() { - None - } else { - Some(out) - } + Some(out) } // ── Extend helpers ──────────────────────────────────────────────────────── @@ -1650,6 +1825,11 @@ fn crossing_trim_lwpolyline( }; let mut removed = Vec::<(f64, f64)>::new(); + + // Compute the polyline's own non-adjacent segment intersections once. + // These global parameters will later be mapped back into each source segment. + let self_cuts = lwpoly_self_cut_params(poly); + for i in 0..seg_count { let a = vertex_xy(i); let b = vertex_xy(i + 1); @@ -1678,42 +1858,55 @@ fn crossing_trim_lwpolyline( let Some((inside_lo, inside_hi)) = window_range else { continue; }; - let cuts = polyline_seg_ts(a, b, bulge, handle, geos); - if cuts.is_empty() { - continue; + // External intersections on this segment. + // External intersections on this segment. + let mut cuts = polyline_seg_ts(a, b, bulge, handle, geos); + + // Also include self-intersections belonging to this source segment. + // `lwpoly_self_cut_params()` stores them as global polyline parameters: + // segment_index + local_parameter + // + // Convert the ones belonging to segment `i` back to local 0..1. + let seg_start = i as f64; + let seg_end = seg_start + 1.0; + + for global_t in self_cuts.iter().copied() { + if global_t > seg_start + 1e-6 + && global_t < seg_end - 1e-6 + { + cuts.push(global_t - seg_start); + } } - let pick_t = if let Some(ba) = BulgeArc::from_bulge(a, b, bulge) { - let angle = (window.pick[1] - ba.center[1]).atan2(window.pick[0] - ba.center[0]); - let travelled = if ba.sweep >= 0.0 { - (angle - ba.start_angle).rem_euclid(TAU) - } else { - -((ba.start_angle - angle).rem_euclid(TAU)) - }; - (travelled / ba.sweep).clamp(inside_lo, inside_hi) - } else { - let dx = b[0] - a[0]; - let dy = b[1] - a[1]; - let len2 = dx * dx + dy * dy; - let projected = if len2 > 1e-12 { - ((window.pick[0] - a[0]) * dx + (window.pick[1] - a[1]) * dy) / len2 - } else { - (inside_lo + inside_hi) * 0.5 - }; - projected.clamp(inside_lo, inside_hi) - }; + // The source segment endpoints are also valid trim limits. + cuts.push(0.0); + cuts.push(1.0); - let mut bounds = vec![0.0]; - bounds.extend(cuts); - bounds.push(1.0); - bounds.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - if let Some(span) = bounds - .windows(2) - .find(|span| pick_t >= span[0] - 1e-6 && pick_t <= span[1] + 1e-6) - { - if span[1] - span[0] > 1e-6 { - removed.push((i as f64 + span[0], i as f64 + span[1])); + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + + // Remove every trim span that is actually touched by the selection box. + // + // A single source segment may contain several sub-spans when external + // boundaries cross it. Only the sub-spans overlapping the window are removed. + for span in cuts.windows(2) { + let lo = span[0].clamp(0.0, 1.0); + let hi = span[1].clamp(0.0, 1.0); + + if hi - lo <= 1e-6 { + continue; + } + + // This trim span participates when its parameter interval overlaps the + // portion of the source segment lying inside the crossing rectangle. + let overlaps_window = + hi >= inside_lo - 1e-6 && lo <= inside_hi + 1e-6; + + if overlaps_window { + removed.push(( + i as f64 + lo, + i as f64 + hi, + )); } } } From 0826101c5edae7da9db52cf504b2406ed6122ca2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:17:38 +0300 Subject: [PATCH 03/13] Blocks palette: tabs, options, drag insertion and -INSERT Rebuild the Blocks palette with Current Drawing / Recent / Favorites / Libraries tabs, wildcard filter, six views, insertion options, click and drop insertion and a block context menu. INSERT opens the palette, the new -INSERT runs on the command line, the ribbon Insert button becomes a block gallery, and BLOCKMRULIST, BLOCKREDEFINEMODE, BLOCKNAVIGATE, BLOCKSTATE, EXPLMODE and INSNAME are added. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 34 + locales/bg-BG/opencadstudio.ftl | 34 + locales/cs-CZ/opencadstudio.ftl | 34 + locales/de-DE/opencadstudio.ftl | 34 + locales/el-GR/opencadstudio.ftl | 34 + locales/en-US/opencadstudio.ftl | 34 + locales/es-ES/opencadstudio.ftl | 34 + locales/fi-FI/opencadstudio.ftl | 34 + locales/fr-FR/opencadstudio.ftl | 34 + locales/hi-IN/opencadstudio.ftl | 34 + locales/hu-HU/opencadstudio.ftl | 34 + locales/it-IT/opencadstudio.ftl | 34 + locales/ja-JP/opencadstudio.ftl | 34 + locales/ko-KR/opencadstudio.ftl | 34 + locales/nl-NL/opencadstudio.ftl | 34 + locales/pl-PL/opencadstudio.ftl | 34 + locales/pt-BR/opencadstudio.ftl | 34 + locales/ru-RU/opencadstudio.ftl | 34 + locales/tr-TR/opencadstudio.ftl | 34 + locales/zh-CN/opencadstudio.ftl | 34 + locales/zh-TW/opencadstudio.ftl | 34 + src/app/command_driver/entity_commit.rs | 23 +- src/app/command_driver/mod.rs | 26 + src/app/commands/blocks.rs | 89 ++- src/app/commands/blockvars.rs | 125 ++++ src/app/commands/dim.rs | 21 + src/app/commands/mod.rs | 12 + src/app/commands/styleprops.rs | 1 + src/app/control/actions.rs | 88 +++ src/app/control/entities.rs | 2 +- src/app/control/mod.rs | 1 + src/app/mod.rs | 12 + src/app/settings.rs | 98 +++ src/app/update/blocks_palette.rs | 603 +++++++++++++++ src/app/update/dialog.rs | 160 ++-- src/app/update/file.rs | 19 + src/app/update/mod.rs | 4 + src/app/update/viewport.rs | 5 + src/app/view/mod.rs | 15 +- src/command.rs | 12 + src/locale_catalog.rs | 34 + src/modules/draw/mod.rs | 2 +- src/modules/insert/insert_block.rs | 902 +++++++++++++++++------ src/modules/insert/mod.rs | 2 +- src/scene/entity.rs | 40 + src/ui/ribbon/mod.rs | 15 +- src/ui/ribbon/widgets.rs | 4 +- src/ui/window/block_palette.rs | 939 ++++++++++++++++++------ src/ui/window/pdf_dialogs.rs | 6 +- 49 files changed, 3401 insertions(+), 573 deletions(-) create mode 100644 src/app/commands/blockvars.rs create mode 100644 src/app/update/blocks_palette.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 49b2b7ec2..b11b307a2 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4032,6 +4032,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 11eec1078..c52fbc5d0 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ecef5c891..a9b707039 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -4017,6 +4017,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 9f6184f50..9d6a85a0c 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -4018,6 +4018,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index c4fa2d108..af55a9273 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index f958e0a44..8d0d15a28 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4021,6 +4021,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 9adc06b83..e9aeb37f4 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 20dd5dfa0..653040ffb 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 01c1d1bb6..e65125269 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index ed27d6574..5f35ca1ae 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -4008,6 +4008,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index bc78e77d9..b3fca5218 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 835f99590..f933595f0 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 969801da3..afba393bd 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -4008,6 +4008,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 8cce74f8e..5fcd3d4e1 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index a1f00441a..495fd45f3 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -4018,6 +4018,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index f48cd9afb..87db5e698 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index ca46a1361..fc8d3184d 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index dc296c1f3..af45f94bc 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4021,6 +4021,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 0c73a6649..e3d231679 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3980,6 +3980,40 @@ insert = .pcs-positive = Pozitif bir sayı gerekir. .pcs-positive-int = Pozitif bir tamsayı gerekir. .pcs-help = Canlı kesitin korunan tarafındaki noktaları çizgilerle izler. + .bp-add-favorite = Sık kullanılanlara ekle + .bp-annotative-dynamic = Açıklayıcı / Dinamik + .bp-auto-placement = Otomatik yerleştirme + .bp-gallery-libraries = Kütüphanelerden bloklar... + .bp-choose-library = … ile bir kütüphane klasörü seçin + .bp-tab-current = Geçerli Çizim + .bp-heading-current = Geçerli çizimdeki bloklar + .bp-view-xl = Çok büyük simgeler + .bp-heading-favorites = Sık kullanılan bloklar + .bp-gallery-favorites = Sık kullanılan bloklar... + .bp-tab-favorites = Sık Kullanılan + .bp-filter = Filtre... (* ? joker) + .bp-icon = Simge + .bp-insertion-point = Ekleme noktası + .bp-view-large = Büyük simgeler + .bp-tab-libraries = Kütüphaneler + .bp-heading-libraries = Kütüphane blokları + .bp-view-medium = Orta simgeler + .bp-tab-recent = Son Kullanılan + .bp-heading-recent = Son kullanılan bloklar + .bp-gallery-recent = Son kullanılan bloklar... + .bp-redefine = Yeniden tanımla + .bp-remove-block = Bloğu kaldır + .bp-remove-favorite = Sık kullanılanlardan çıkar + .bp-remove-list = Listeden kaldır + .bp-repeat-placement = Yerleştirmeyi yinele + .bp-select-library = Blok kütüphanesi klasörü seçin + .bp-view-small = Küçük simgeler + .bp-on-screen = ekranda belirtilir + .bp-block-not-found = "__ocs_fmt_0__" bloğu bulunamadı. + .bp-block-redefined = "__ocs_fmt_0__" bloğu yeniden tanımlandı. + .bp-block-no-reference = "__ocs_fmt_0__" bloğunun çizimde düzenlenecek bir başvurusu yok. + .bp-block-referenced = "__ocs_fmt_0__" bloğu çizimde kullanılıyor, kaldırılamaz. + .bp-block-removed = "__ocs_fmt_0__" bloğu kaldırıldı. model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index be1fdc04e..9c7572400 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4003,6 +4003,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 38707859d..ff26b0b01 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -4015,6 +4015,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index 2fcb4e256..5585e04d6 100644 --- a/src/app/command_driver/entity_commit.rs +++ b/src/app/command_driver/entity_commit.rs @@ -748,6 +748,7 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("Block \"{name}\" created.").as_ref()); self.refresh_properties(); + self.note_recent_block(&name); self.refresh_block_palette(); } Err(err) => { @@ -985,7 +986,16 @@ impl OpenCADStudio { self.commit_entity(entity); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; + // A repeating insertion keeps its command for the next block. + let keep = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.attreq_continue()); + if keep { + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } else { + self.tabs[i].active_cmd = None; + } self.tabs[i].snap_result = None; if let Some(n) = insert_name { self.record_block_insert(&n); @@ -1001,7 +1011,16 @@ impl OpenCADStudio { .as_mut() .and_then(|cmd| cmd.attreq_set_attdefs(attdefs)); if let Some(entity) = completed { - return Some(self.apply_cmd_result(CmdResult::CommitAndExit(entity))); + if let codec::EntityType::Insert(ins) = &entity { + let name = ins.block_name.clone(); + self.record_block_insert(&name); + } + let keep = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.attreq_continue()); + return Some(self.apply_cmd_result(if keep { + CmdResult::CommitEntity(entity) + } else { + CmdResult::CommitAndExit(entity) + })); } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 34cbd7f4f..76ba3bfb0 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -767,6 +767,32 @@ impl OpenCADStudio { return task; } } + CmdResult::CommitExplodedInsert { insert, keep_going } => { + let i = self.active_tab; + if let codec::EntityType::Insert(ins) = &insert { + let name = ins.block_name.clone(); + self.record_block_insert(&name); + } + let pieces = crate::modules::draw::modify::explode::explode_entity( + &insert, + &self.tabs[i].scene.document, + ); + if keep_going { + if let Some(task) = self.handle_commit_entities( + pieces, + preserve_commit_style, + preserve_commit_layer, + ) { + return task; + } + } else { + self.handle_commit_entities_and_exit( + pieces, + preserve_commit_style, + preserve_commit_layer, + ); + } + } CmdResult::AttreqNeeded { block_name } => { if let Some(task) = self.handle_attreq_needed(block_name) { return task; diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 7f671436b..9f9cc1aea 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -489,25 +489,50 @@ impl OpenCADStudio { self.active_modal = Some(crate::app::ModalKind::WriteBlock); } + // INSERT opens the Blocks palette on the drawing's blocks; + // -INSERT runs on the command line. "INSERT" => { + self.command_line.push_output("*Insert a block from the Blocks palette"); + self.open_blocks_palette(Some(crate::ui::window::block_palette::Tab::Current)); + } + + "-INSERT" => { + use crate::command::CadCommand; + use crate::modules::insert::insert_block::InsertBlockCommand; let blocks = self.tabs[i].scene.custom_block_names(); - if blocks.is_empty() { - self.command_line.push_error( - crate::t!("No user-defined blocks found in this drawing.").as_ref(), - ); - } else { - use crate::modules::insert::insert_block::InsertBlockCommand; - let ranked = self.ranked_block_names(&blocks); - let snapshot = self.block_usage_snapshot(); - let cmd = InsertBlockCommand::new_with_usage( - ranked, - snapshot, - self.cliprompt_lines.clamp(0, 50) as u8, - ); - self.command_line.push_info(&cmd.prompt()); - let opts = cmd.options(); - self.command_line.set_step_options(opts.clone()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); + let ranked = self.ranked_block_names(&blocks); + let snapshot = self.block_usage_snapshot(); + let cmd = InsertBlockCommand::classic( + ranked, + snapshot, + self.cliprompt_lines.clamp(0, 50) as u8, + self.block_catalog(), + self.insname.clone(), + ); + self.command_line.push_info(&cmd.prompt()); + self.command_line.set_step_options(cmd.options()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + + // A drawing named at -INSERT's block prompt: brought in as a + // block, then placed. + cmd if cmd.starts_with("_-INSERTFILE ") => { + use crate::command::CadCommand; + use crate::modules::insert::insert_block::InsertBlockCommand; + let path = std::path::PathBuf::from(cmd["_-INSERTFILE ".len()..].trim().trim_matches('"')); + match self.import_file_as_block(path) { + Ok(name) => { + let units = self + .block_catalog() + .units + .remove(&name.to_ascii_uppercase()) + .unwrap_or_default(); + let preview = self.tabs[i].scene.block_preview_wires(&name); + let cmd = InsertBlockCommand::classic_for_block(name, preview, units); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + Err(e) => self.command_line.push_error(e.as_str()), } } @@ -667,15 +692,27 @@ impl OpenCADStudio { ); } - // BLOCKPALETTE / BLOCKSPALETTE — toggle the docked Insert Block panel. - "BLOCKPALETTE" | "BLOCKSPALETTE" => { - self.show_block_palette ^= true; - if self.show_block_palette { - // Always open expanded so the panel is immediately usable; - // the user can still collapse it via the pin (Auto) button. - self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette); - self.refresh_block_palette(); - } + // BLOCKSPALETTE opens the Blocks palette; BLOCKSPALETTECLOSE + // closes it. `_BLOCKSPALETTE ` opens it on a tab. + "BLOCKPALETTE" | "BLOCKSPALETTE" => self.open_blocks_palette(None), + "BLOCKSPALETTECLOSE" => { + self.show_block_palette = false; + self.block_palette.placing = None; + } + // A block picked in the ribbon gallery. + cmd if cmd.starts_with("_BLOCKINSERT ") => { + let name = cmd["_BLOCKINSERT ".len()..].trim().to_string(); + return Some(self.palette_insert(crate::ui::window::block_palette::Item::Current(name), None)); + } + cmd if cmd.starts_with("_BLOCKSPALETTE ") => { + use crate::ui::window::block_palette::Tab; + let tab = match cmd["_BLOCKSPALETTE ".len()..].trim() { + "RECENT" => Tab::Recent, + "FAVORITES" => Tab::Favorites, + "LIBRARIES" => Tab::Libraries, + _ => Tab::Current, + }; + self.open_blocks_palette(Some(tab)); } // ATTMAN / BATTMAN — the Block Attribute Manager. Rather than a diff --git a/src/app/commands/blockvars.rs b/src/app/commands/blockvars.rs new file mode 100644 index 000000000..fae4f4eae --- /dev/null +++ b/src/app/commands/blockvars.rs @@ -0,0 +1,125 @@ +//! Block system variables. `BLOCKMRULIST`, `BLOCKREDEFINEMODE` and +//! `BLOCKNAVIGATE` are application preferences kept with the settings; +//! `EXPLMODE` lives in the drawing; `INSNAME` is the session's default block +//! name; `BLOCKSTATE` is read-only. Each answers to its bare name (prompting +//! for the value) and to `SETVAR NAME value`, with the reference's messages. + +use crate::app::{Message, OpenCADStudio}; +use iced::Task; + +/// The variables this family answers for, for the command registry and the +/// `SETVAR ?` listing. +pub(super) const BLOCK_SYSVARS: &[&str] = &[ + "BLOCKMRULIST", + "BLOCKREDEFINEMODE", + "BLOCKNAVIGATE", + "BLOCKSTATE", + "EXPLMODE", + "INSNAME", +]; + +/// The `SETVAR ?` line for this family. +pub(super) fn setvar_listing() -> String { + format!("SETVAR (block): {}", BLOCK_SYSVARS.join(" ")) +} + +/// EXPLMODE of the drawing (default 1). +pub(crate) fn explmode(document: &codec::CadDocument) -> i16 { + crate::io::drawing_variable(document, "EXPLMODE") + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(1) +} + +impl OpenCADStudio { + pub(super) fn dispatch_blockvars(&mut self, cmd: &str, i: usize) -> Option> { + let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd); + let mut parts = rest.splitn(2, char::is_whitespace); + let name = parts.next().unwrap_or("").to_ascii_uppercase(); + if !BLOCK_SYSVARS.contains(&name.as_str()) { + return None; + } + let value = parts.next().map(str::trim).filter(|v| !v.is_empty()); + match name.as_str() { + "BLOCKSTATE" => { + let state = self.tabs[i].refedit_session.is_some() as u8; + self.command_line.push_output(&format!("BLOCKSTATE = {state} (read only)")); + } + "INSNAME" | "BLOCKNAVIGATE" => { + let current = if name == "INSNAME" { + self.insname.clone() + } else { + self.block_navigate.clone() + }; + match value { + Some(v) => { + let v = if v == "." { String::new() } else { v.trim_matches('"').to_string() }; + if name == "INSNAME" { + self.insname = v; + } else { + self.block_navigate = if v.is_empty() { ".".to_string() } else { v }; + self.persist_settings_if_changed(); + } + } + None => { + self.command_line.push_output(&format!( + "Enter new value for {name}, or . for none <\"{current}\">:" + )); + self.pending_setvar = Some(name); + } + } + } + _ => { + let (current, max) = match name.as_str() { + "BLOCKMRULIST" => (self.block_mru_list as i32, 100), + "BLOCKREDEFINEMODE" => (self.block_redefine_mode as i32, 2), + _ => (explmode(&self.tabs[i].scene.document) as i32, 1), + }; + let ask = |app: &mut Self, name: String| { + app.command_line + .push_output(&format!("Enter new value for {name} <{current}>:")); + app.pending_setvar = Some(name); + }; + match value { + None => ask(self, name), + Some(v) => match v.parse::() { + Err(_) => { + self.command_line.push_error("Requires an integer value."); + ask(self, name); + } + Ok(n) if !(0..=max).contains(&n) => { + self.command_line.push_error(&if max == 1 { + "Requires 0 or 1 only.".to_string() + } else { + format!("Requires an integer between 0 and {max}.") + }); + ask(self, name); + } + Ok(n) => match name.as_str() { + "BLOCKMRULIST" => { + self.block_mru_list = n as u8; + self.trim_recent_blocks(); + self.persist_settings_if_changed(); + } + "BLOCKREDEFINEMODE" => { + self.block_redefine_mode = n as u8; + self.persist_settings_if_changed(); + } + _ => { + if n != current { + self.push_undo_snapshot(i, "EXPLMODE"); + crate::io::set_drawing_variable( + &mut self.tabs[i].scene.document, + "EXPLMODE", + &n.to_string(), + ); + self.tabs[i].dirty = true; + } + } + }, + }, + } + } + } + Some(Task::none()) + } +} diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 770007d25..2bc34742a 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1217,6 +1217,27 @@ impl OpenCADStudio { self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { + // EXPLMODE 0 keeps references that are not uniformly and + // positively scaled whole. + let selected: Vec<_> = if super::blockvars::explmode(&self.tabs[i].scene.document) == 0 { + selected + .into_iter() + .filter(|(_, entity)| match entity { + codec::EntityType::Insert(ins) => { + let (x, y, z) = (ins.x_scale(), ins.y_scale(), ins.z_scale()); + let uniform = x > 0.0 && (x - y).abs() <= 1e-10 && (x - z).abs() <= 1e-10; + if !uniform { + self.command_line + .push_output("The object is not uniformly and positively scaled."); + } + uniform + } + _ => true, + }) + .collect() + } else { + selected + }; let replacements = plan_explode(&selected, &self.tabs[i].scene.document); let exploded = replacements.len(); if exploded > 0 { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index c39a58a6a..3c8b19754 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -20,6 +20,7 @@ mod pc_extract; mod pdf_underlay; mod pdf_dialogs; mod xclip; +mod blockvars; mod plotvars; mod styleprops; mod view; @@ -296,6 +297,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_plotvars(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_blockvars(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_styleprops(cmd, i) { return Some(t); } @@ -473,6 +477,14 @@ inventory::submit!(crate::command::CommandRegistration { // Block list + block-attribute list (command-line forms). "BLOCKPALETTE", "BLOCKSPALETTE", + "BLOCKSPALETTECLOSE", + "-INSERT", + "BLOCKMRULIST", + "BLOCKREDEFINEMODE", + "BLOCKNAVIGATE", + "BLOCKSTATE", + "EXPLMODE", + "INSNAME", "ATTMAN", "BATTMAN", // Drawing-content overview. diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 8479f551a..2040e8f10 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1107,6 +1107,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info(&super::plotvars::setvar_listing()); + self.command_line.push_info(&super::blockvars::setvar_listing()); self.command_line.push_info( crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 274f1f1da..35566cd2f 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -73,6 +73,7 @@ pub(super) const NAMES: &[&str] = &[ "pdf_layer_toggle", "pc_manager_toggle", "pc_manager", + "blocks_palette", "pdf_page_select", "pc_colormap", "pc_section", @@ -768,6 +769,93 @@ impl OpenCADStudio { _ => return Err(failure("invalid_value", "Unknown point cloud manager edit")), }) } + // Blocks palette: "tab=recent", "search=D*", "view=2", + // "option=x:2", "options", "library=", "insert=", + // "drop=@x,y", "menu=:". An item is + // "current:NAME", "recent:N", "fav:N", "lib:N" or "file:". + "blocks_palette" => { + use crate::ui::window::block_palette::{BlockPaletteMsg as M, Item, MenuAction, OptionMsg, Tab, ViewMode}; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + let item = |app: &Self, spec: &str| -> Result { + let (kind, rest) = spec.split_once(':').unwrap_or((spec, "")); + let index = || rest.parse::().map_err(|_| failure("invalid_value", "Bad item index")); + let missing = || failure("invalid_value", "No such palette item"); + Ok(match kind { + "current" => Item::Current(rest.to_string()), + "recent" => Item::Ref(app.block_palette.recent.get(index()?).cloned().ok_or_else(missing)?), + "fav" => Item::Ref(app.block_palette.favorites.get(index()?).cloned().ok_or_else(missing)?), + "lib" => { + let e = app.block_palette.library_entries.get(index()?).ok_or_else(missing)?; + if e.folder { Item::Folder(e.path.clone()) } else { Item::File(e.path.clone()) } + } + "file" => Item::File(std::path::PathBuf::from(rest)), + _ => return Err(missing()), + }) + }; + match key { + "tab" => Message::BlockPalette(M::Tab(match arg { + "recent" => Tab::Recent, + "favorites" => Tab::Favorites, + "libraries" => Tab::Libraries, + _ => Tab::Current, + })), + "search" => Message::BlockPalette(M::Search(arg.into())), + "view" => Message::BlockPalette(M::View(ViewMode::from_u8(arg.parse().unwrap_or(1)))), + "options" => Message::BlockPalette(M::ToggleOptions), + "library" => Message::BlockPalette(M::LibraryPicked(Ok(arg.into()))), + "up" => Message::BlockPalette(M::LibraryUp), + "option" => { + let (name, v) = arg.split_once(':').unwrap_or((arg, "1")); + let on = v == "1"; + Message::BlockPalette(M::Option(match name { + "insertion_point" => OptionMsg::InsertionPoint(on), + "scale" => OptionMsg::Scale(on), + "uniform" => OptionMsg::Uniform(on), + "rotation" => OptionMsg::Rotation(on), + "auto" => OptionMsg::AutoPlacement(on), + "repeat" => OptionMsg::Repeat(on), + "explode" => OptionMsg::Explode(on), + "x" => OptionMsg::X(v.into()), + "y" => OptionMsg::Y(v.into()), + "z" => OptionMsg::Z(v.into()), + "angle" => OptionMsg::Angle(v.into()), + _ => return Err(failure("invalid_value", "Unknown palette option")), + })) + } + "insert" => { + let it = item(self, arg)?; + self.block_palette.pressed = Some(it.clone()); + Message::BlockPalette(M::Release(it)) + } + "drop" => { + let (spec, at) = arg.rsplit_once('@').ok_or_else(|| failure("invalid_value", "drop needs @x,y"))?; + let xy: Vec = at.split(',').filter_map(|v| v.trim().parse().ok()).collect(); + if xy.len() < 2 { + return Err(failure("invalid_value", "drop needs @x,y")); + } + let it = item(self, spec)?; + let i = self.active_tab; + self.tabs[i].last_cursor_world = glam::DVec3::new(xy[0], xy[1], xy.get(2).copied().unwrap_or(0.0)); + self.block_palette.pressed = Some(it); + Message::ViewportLeftRelease + } + "menu" => { + let (action, spec) = arg.split_once(':').ok_or_else(|| failure("invalid_value", "menu needs action:item"))?; + let action = match action { + "insert" => MenuAction::Insert, + "redefine" => MenuAction::Redefine, + "favorite" => MenuAction::Favorite, + "unfavorite" => MenuAction::Unfavorite, + "edit" => MenuAction::Edit, + "remove" => MenuAction::Remove, + _ => return Err(failure("invalid_value", "Unknown menu action")), + }; + Message::BlockPalette(M::Menu(item(self, spec)?, action)) + } + _ => return Err(failure("invalid_value", "Unknown blocks palette edit")), + } + } // Attach dialog: choose pages by index ("0,2"). "pdf_page_select" => { let pages: Vec = string(req, "value")? diff --git a/src/app/control/entities.rs b/src/app/control/entities.rs index 7eb5acd64..c4da959ac 100644 --- a/src/app/control/entities.rs +++ b/src/app/control/entities.rs @@ -676,7 +676,7 @@ impl OpenCADStudio { /// Silent no-op for an unknown name (callers validate first); /// `*`-prefixed layout records are rejected by the callers. Returns the /// number of entities erased. -fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize { +pub(crate) fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize { let Some(record) = scene.document.block_records.get(name) else { return 0; }; diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index e2e64735a..5ed5c5657 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1340,6 +1340,7 @@ impl OpenCADStudio { mod actions; pub(crate) use actions::property_json; mod entities; +pub(crate) use entities::erase_block_definition; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod http_bridge; mod interactive; diff --git a/src/app/mod.rs b/src/app/mod.rs index 65df40ec0..01f2f6412 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -671,6 +671,14 @@ pub(super) struct OpenCADStudio { block_mru: Vec, /// Insertion frequency per block name (uppercase key → count), capped. block_freq: std::collections::HashMap, + /// BLOCKMRULIST: how many recent blocks the palette keeps (0–100). + pub(crate) block_mru_list: u8, + /// BLOCKREDEFINEMODE (0–2). + pub(crate) block_redefine_mode: u8, + /// BLOCKNAVIGATE: the Libraries tab's start folder ("." for none). + pub(crate) block_navigate: String, + /// INSNAME: the default block name for -INSERT (session only). + pub(crate) insname: String, /// Last time block-usage was flushed to disk (debounce per 2.4). #[cfg(not(target_arch = "wasm32"))] block_usage_last_persist: Option, @@ -4142,6 +4150,10 @@ impl OpenCADStudio { commandline_fade_ms: 3000, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), + block_mru_list: 50, + block_redefine_mode: 1, + block_navigate: ".".to_string(), + insname: String::new(), #[cfg(not(target_arch = "wasm32"))] block_usage_last_persist: None, awaiting_vports: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index f4a62191c..c85f736bd 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -546,6 +546,96 @@ pub struct UserSettings { /// Insertion frequency per block name (uppercase key → count). #[serde(default)] pub block_freq: std::collections::HashMap, + /// Blocks palette: the recent list (newest first, BLOCKMRULIST long). + #[serde(default)] + pub block_recent: Vec, + /// Blocks palette: favorites, kept locally. + #[serde(default)] + pub block_favorites: Vec, + /// Blocks palette: library folders or drawings, last used first. + #[serde(default)] + pub block_libraries: Vec, + /// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons, + /// 4 details, 5 list. + #[serde(default = "default_block_view")] + pub block_palette_view: u8, + /// Blocks palette insertion options. + #[serde(default)] + pub block_insert: BlockInsertOptions, + /// BLOCKMRULIST (0–100, default 50). + #[serde(default = "default_block_mru_list")] + pub block_mru_list: u8, + /// BLOCKREDEFINEMODE (0–2, default 1). + #[serde(default = "default_one_u8")] + pub block_redefine_mode: u8, + /// BLOCKNAVIGATE (default "."). + #[serde(default = "default_block_navigate")] + pub block_navigate: String, +} + +/// A block the Blocks palette lists from outside the current drawing (a +/// recent or favorite entry): its name, the drawing it came from (empty for +/// an unsaved drawing), when it was last used (Unix seconds), and whether it +/// stands for a whole drawing inserted as a block. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PaletteBlockRef { + pub name: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub time: u64, + #[serde(default)] + pub drawing: bool, +} + +/// The Blocks palette's insertion options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct BlockInsertOptions { + pub insertion_point: bool, + pub scale: bool, + pub uniform: bool, + pub x: f64, + pub y: f64, + pub z: f64, + pub rotation: bool, + pub angle: f64, + pub auto_placement: bool, + pub repeat: bool, + pub explode: bool, +} + +impl Default for BlockInsertOptions { + fn default() -> Self { + Self { + insertion_point: true, + scale: false, + uniform: false, + x: 1.0, + y: 1.0, + z: 1.0, + rotation: false, + angle: 0.0, + auto_placement: true, + repeat: false, + explode: false, + } + } +} + +fn default_block_view() -> u8 { + 1 +} + +fn default_block_mru_list() -> u8 { + 50 +} + +fn default_one_u8() -> u8 { + 1 +} + +fn default_block_navigate() -> String { + ".".to_string() } fn default_clipromptlines() -> i32 { @@ -710,6 +800,14 @@ impl Default for UserSettings { grid_beyond_limits: true, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), + block_recent: Vec::new(), + block_favorites: Vec::new(), + block_libraries: Vec::new(), + block_palette_view: 1, + block_insert: BlockInsertOptions::default(), + block_mru_list: 50, + block_redefine_mode: 1, + block_navigate: ".".to_string(), } } } diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs new file mode 100644 index 000000000..8c88eb728 --- /dev/null +++ b/src/app/update/blocks_palette.rs @@ -0,0 +1,603 @@ +//! The Blocks palette on the app side: tabs, the recent / favorite lists, +//! library folders, the insertion options, click and drag insertion, and the +//! block context menu. + +use std::path::{Path, PathBuf}; + +use crate::app::settings::PaletteBlockRef; +use crate::app::{Message, OpenCADStudio}; +use crate::modules::insert::insert_block::{BlockCatalog, InsertBlockCommand}; +use crate::ui::window::block_palette::{ + BlockEntry, BlockPaletteMsg, Item, LibraryEntry, MenuAction, OptionMsg, Tab, +}; +use iced::Task; + +/// Library folders remembered in the Libraries list. +const MAX_LIBRARIES: usize = 10; + +fn now_secs() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } + #[cfg(target_arch = "wasm32")] + { + 0 + } +} + +fn file_stem(path: &Path) -> String { + path.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Block".to_string()) +} + +fn is_drawing(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("dwg") || e.eq_ignore_ascii_case("dxf")) +} + +impl OpenCADStudio { + pub(super) fn on_block_palette(&mut self, m: BlockPaletteMsg) -> Task { + match m { + BlockPaletteMsg::Search(s) => self.block_palette.search = s, + BlockPaletteMsg::Tab(tab) => self.show_block_palette_tab(tab), + BlockPaletteMsg::View(v) => { + self.block_palette.view = v; + self.persist_settings_if_changed(); + } + BlockPaletteMsg::Refresh => self.refresh_block_palette(), + BlockPaletteMsg::PickFile => { + return Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) + .add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + |r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)), + ); + } + BlockPaletteMsg::FilePicked(Ok(path)) => return self.palette_insert(Item::File(path), None), + BlockPaletteMsg::FilePicked(Err(e)) | BlockPaletteMsg::LibraryPicked(Err(e)) => { + if e != "Cancelled" { + self.command_line.push_error(&e); + } + } + BlockPaletteMsg::Option(o) => self.on_block_option(o), + BlockPaletteMsg::ToggleOptions => { + self.block_palette.options_collapsed ^= true; + } + BlockPaletteMsg::LibraryBrowse => { + return Task::perform( + async { + match rfd::AsyncFileDialog::new() + .set_title(crate::t!("Select a Block Library Folder").as_ref()) + .pick_folder() + .await + { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + |r| Message::BlockPalette(BlockPaletteMsg::LibraryPicked(r)), + ); + } + BlockPaletteMsg::LibraryPicked(Ok(path)) => self.open_block_library(path), + BlockPaletteMsg::LibrarySelect(path) => self.open_block_library(PathBuf::from(path)), + BlockPaletteMsg::LibraryUp => { + if let Some(parent) = self.block_palette.library_dir.as_ref().and_then(|d| d.parent()) { + let parent = parent.to_path_buf(); + self.list_block_library(parent); + } + } + BlockPaletteMsg::Press(item) => self.block_palette.pressed = Some(item), + BlockPaletteMsg::Release(item) => { + if self.block_palette.pressed.take().as_ref() == Some(&item) { + return match item { + Item::Folder(dir) => { + self.list_block_library(dir); + Task::none() + } + item => self.palette_insert(item, None), + }; + } + } + BlockPaletteMsg::Menu(item, action) => return self.on_block_menu(item, action), + } + Task::none() + } + + /// BLOCKSPALETTE: open (expanded) on `tab`, or on the tab it was left on. + pub(crate) fn open_blocks_palette(&mut self, tab: Option) { + self.show_block_palette = true; + self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette); + self.show_block_palette_tab(tab.unwrap_or(self.block_palette.tab)); + } + + fn show_block_palette_tab(&mut self, tab: Tab) { + self.block_palette.tab = tab; + if tab == Tab::Libraries && self.block_palette.library_dir.is_none() { + let start = Some(self.block_navigate.trim()) + .filter(|p| !p.is_empty() && *p != "." && Path::new(p).is_dir()) + .map(PathBuf::from) + .or_else(|| self.block_palette.libraries.first().map(PathBuf::from)); + if let Some(dir) = start { + self.list_block_library(dir); + } + } + self.refresh_block_palette(); + } + + fn open_block_library(&mut self, root: PathBuf) { + let key = root.display().to_string(); + let libraries = &mut self.block_palette.libraries; + libraries.retain(|l| l != &key); + libraries.insert(0, key); + libraries.truncate(MAX_LIBRARIES); + self.list_block_library(root); + self.persist_settings_if_changed(); + } + + /// Show `dir`: its sub-folders, then its drawings, each by name. + fn list_block_library(&mut self, dir: PathBuf) { + let mut entries: Vec = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let path = e.path(); + // Hidden folders (".name") hold tool data, not blocks. + if e.file_name().to_string_lossy().starts_with('.') { + return None; + } + if path.is_dir() { + Some(LibraryEntry { path, folder: true, thumb: None }) + } else if is_drawing(&path) { + let thumb = crate::io::thumbnail::read_handle(&path); + Some(LibraryEntry { path, folder: false, thumb }) + } else { + None + } + }) + .collect(); + entries.sort_by_key(|e| { + (!e.folder, e.path.file_name().map(|n| n.to_string_lossy().to_lowercase())) + }); + self.block_palette.library_dir = Some(dir); + self.block_palette.library_entries = entries; + } + + fn on_block_option(&mut self, o: OptionMsg) { + let p = &mut self.block_palette; + let number = |t: &str| t.trim().parse::().ok().filter(|v| v.is_finite()); + match o { + OptionMsg::InsertionPoint(v) => p.options.insertion_point = v, + OptionMsg::Scale(v) => p.options.scale = v, + OptionMsg::Uniform(v) => p.options.uniform = v, + OptionMsg::Rotation(v) => p.options.rotation = v, + OptionMsg::AutoPlacement(v) => p.options.auto_placement = v, + OptionMsg::Repeat(v) => p.options.repeat = v, + OptionMsg::Explode(v) => p.options.explode = v, + OptionMsg::X(t) => { + if let Some(v) = number(&t).filter(|v| *v != 0.0) { + p.options.x = v; + } + p.option_text[0] = t; + } + OptionMsg::Y(t) => { + if let Some(v) = number(&t).filter(|v| *v != 0.0) { + p.options.y = v; + } + p.option_text[1] = t; + } + OptionMsg::Z(t) => { + if let Some(v) = number(&t).filter(|v| *v != 0.0) { + p.options.z = v; + } + p.option_text[2] = t; + } + OptionMsg::Angle(t) => { + if let Some(v) = number(&t) { + p.options.angle = v; + } + p.option_text[3] = t; + } + } + self.persist_settings_if_changed(); + } + + /// Start placing `name` as a palette click would. + #[cfg(test)] + pub(super) fn start_block_placement(&mut self, name: &str) { + let _ = self.start_palette_insert(name, None); + } + + fn start_palette_insert(&mut self, name: &str, drop_at: Option) -> Task { + use crate::command::CadCommand; + let i = self.active_tab; + let wires = self + .block_palette + .blocks + .iter() + .find(|b| b.name.eq_ignore_ascii_case(name)) + .map(|b| b.wires.clone()) + .unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name)); + let mut options = self.block_palette.options.clone(); + if drop_at.is_some() { + // A dropped block goes in where it lands with the fixed values. + options.insertion_point = false; + options.scale = false; + options.rotation = false; + options.repeat = false; + } + let mut cmd = InsertBlockCommand::palette(name.to_string(), wires, &options); + if let Some(point) = drop_at { + cmd.set_point(point); + } + self.reset_command_start_state(i); + let plane = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + cmd.set_working_plane(plane); + let first = cmd.initial(); + let prompt = cmd.prompt(); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.block_palette.placing = Some(name.to_string()); + match first { + Some(result) => self.apply_cmd_result(result), + None => { + self.command_line.push_info(&prompt); + Task::none() + } + } + } + + /// Insert `item` (a click, or a drop at `drop_at`), bringing its block + /// into the drawing first when it lives elsewhere. + pub(crate) fn palette_insert(&mut self, item: Item, drop_at: Option) -> Task { + match self.resolve_palette_block(&item) { + Ok(name) => { + self.refresh_block_palette(); + self.start_palette_insert(&name, drop_at) + } + Err(e) => { + if !e.is_empty() { + self.command_line.push_error(&e); + } + Task::none() + } + } + } + + fn block_exists(&self, name: &str) -> bool { + self.tabs[self.active_tab].scene.document.block_records.get(name).is_some() + } + + fn current_drawing_path(&self) -> String { + self.tabs[self.active_tab] + .current_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default() + } + + fn resolve_palette_block(&mut self, item: &Item) -> Result { + match item { + Item::Current(name) => Ok(name.clone()), + Item::Ref(r) if r.drawing => self.block_from_drawing(PathBuf::from(&r.source)), + Item::Ref(r) => { + let elsewhere = !r.source.is_empty() && r.source != self.current_drawing_path(); + if self.block_exists(&r.name) && !(elsewhere && self.block_redefine_mode == 2) { + return Ok(r.name.clone()); + } + if !elsewhere { + return Err(crate::tf!("Block \"{}\" not found.", r.name).into_owned()); + } + let doc = crate::io::load_file(Path::new(&r.source)).map_err(|e| e.to_string())?; + let redefine = self.block_exists(&r.name); + self.import_block_definition(&doc, &r.name, redefine)?; + Ok(r.name.clone()) + } + Item::File(path) => self.block_from_drawing(path.clone()), + Item::Folder(_) => Err(String::new()), + } + } + + /// The block standing for drawing `path`: the drawing's own block when + /// the drawing already has one of that name (redefined from the file + /// under BLOCKREDEFINEMODE 2), else the file inserted as a new block. + fn block_from_drawing(&mut self, path: PathBuf) -> Result { + let stem = file_stem(&path); + let name = if self.block_exists(&stem) && self.block_redefine_mode != 2 { + stem + } else { + self.import_drawing_block(path.clone(), true)? + }; + self.block_palette + .file_sources + .insert(name.to_ascii_uppercase(), path.display().to_string()); + Ok(name) + } + + /// Bring block `name` (and what it needs) from `source` into the active + /// drawing; with `redefine` an existing definition takes the source's. + fn import_block_definition( + &mut self, + source: &codec::CadDocument, + name: &str, + redefine: bool, + ) -> Result<(), String> { + if source.block_records.get(name).is_none() { + return Err(crate::tf!("Block \"{}\" not found.", name).into_owned()); + } + let probe = codec::EntityType::Insert(codec::entities::Insert::new( + name.to_string(), + codec::types::Vector3::ZERO, + )); + let deps = crate::app::ClipboardDeps::capture(source, std::slice::from_ref(&probe)); + let i = self.active_tab; + self.push_undo_snapshot(i, "INSERT"); + self.merge_dependencies(i, &deps); + for def in deps.blocks { + let scene = &mut self.tabs[i].scene; + if redefine && def.name.eq_ignore_ascii_case(name) { + scene.redefine_block_raw(&def.name, def.base_point, def.entities); + } else if scene.document.block_records.get(&def.name).is_none() { + scene.define_block_raw(&def.name, def.base_point, def.entities); + } + } + self.tabs[i].scene.populate_meshes_from_document(); + self.tabs[i].dirty = true; + Ok(()) + } + + fn palette_ref(&self, item: &Item) -> Option { + let time = now_secs(); + match item { + Item::Current(name) => Some(PaletteBlockRef { + name: name.clone(), + source: self.current_drawing_path(), + time, + drawing: false, + }), + Item::Ref(r) => Some(r.clone()), + Item::File(path) => Some(PaletteBlockRef { + name: file_stem(path), + source: path.display().to_string(), + time, + drawing: true, + }), + Item::Folder(_) => None, + } + } + + fn on_block_menu(&mut self, item: Item, action: MenuAction) -> Task { + let same = |a: &PaletteBlockRef, b: &PaletteBlockRef| { + a.name.eq_ignore_ascii_case(&b.name) && a.source == b.source + }; + match action { + MenuAction::Insert => return self.palette_insert(item, None), + MenuAction::Redefine => { + let result = match &item { + Item::Ref(r) if r.drawing => self.import_drawing_block(PathBuf::from(&r.source), true), + Item::File(path) => self.import_drawing_block(path.clone(), true), + Item::Ref(r) if !r.source.is_empty() => crate::io::load_file(Path::new(&r.source)) + .map_err(|e| e.to_string()) + .and_then(|doc| self.import_block_definition(&doc, &r.name, true)) + .map(|_| r.name.clone()), + _ => return Task::none(), + }; + match result { + Ok(name) => { + self.command_line + .push_output(&crate::tf!("Block \"{name}\" redefined.")); + self.refresh_block_palette(); + } + Err(e) => self.command_line.push_error(&e), + } + } + MenuAction::Favorite => { + if let Some(r) = self.palette_ref(&item) { + let favorites = &mut self.block_palette.favorites; + favorites.retain(|f| !same(f, &r)); + favorites.insert(0, r); + self.persist_settings_if_changed(); + self.refresh_block_palette(); + } + } + MenuAction::Unfavorite => { + if let Some(r) = self.palette_ref(&item) { + self.block_palette.favorites.retain(|f| !same(f, &r)); + self.persist_settings_if_changed(); + } + } + MenuAction::Edit => { + let name = item.name(); + let i = self.active_tab; + let reference = self.tabs[i].scene.document.entities().find_map(|e| match e { + codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name) => { + Some(ins.common.handle) + } + _ => None, + }); + match reference { + Some(handle) => return self.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value())), + None => self.command_line.push_error(&crate::tf!( + "Block \"{name}\" has no reference in the drawing to edit." + )), + } + } + MenuAction::Remove => match self.block_palette.tab { + Tab::Current => { + let name = item.name(); + let i = self.active_tab; + let referenced = self.tabs[i].scene.document.entities().any(|e| { + matches!(e, codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name)) + }); + if referenced { + self.command_line.push_error(&crate::tf!( + "Block \"{name}\" is referenced in the drawing and cannot be removed." + )); + } else { + self.push_undo_snapshot(i, "PURGE"); + crate::app::control::erase_block_definition(&mut self.tabs[i].scene, &name); + self.tabs[i].dirty = true; + self.command_line.push_output(&crate::tf!("Block \"{name}\" removed.")); + self.refresh_block_palette(); + } + } + Tab::Recent | Tab::Favorites => { + if let Some(r) = self.palette_ref(&item) { + let list = if self.block_palette.tab == Tab::Recent { + &mut self.block_palette.recent + } else { + &mut self.block_palette.favorites + }; + list.retain(|f| !same(f, &r)); + self.persist_settings_if_changed(); + } + } + Tab::Libraries => {} + }, + } + Task::none() + } + + /// A press in the drawing ends any palette press; a release there with + /// a block still pressed drops it at the cursor. + pub(super) fn take_block_drop(&mut self) -> Option { + self.block_palette.pressed.take() + } + + pub(super) fn drop_block(&mut self, item: Item) -> Task { + let at = self.tabs[self.active_tab].last_cursor_world; + self.palette_insert(item, Some(at)) + } + + /// Put `name` at the top of the Recent list (BLOCKMRULIST long). + pub(crate) fn note_recent_block(&mut self, name: &str) { + let (source, drawing) = match self.block_palette.file_sources.get(&name.to_ascii_uppercase()) { + Some(path) => (path.clone(), true), + None => (self.current_drawing_path(), false), + }; + let entry = PaletteBlockRef { + name: if drawing { file_stem(Path::new(&source)) } else { name.to_string() }, + source, + time: now_secs(), + drawing, + }; + let recent = &mut self.block_palette.recent; + recent.retain(|r| !(r.name.eq_ignore_ascii_case(&entry.name) && r.source == entry.source)); + recent.insert(0, entry); + self.trim_recent_blocks(); + } + + pub(crate) fn trim_recent_blocks(&mut self) { + self.block_palette.recent.truncate(self.block_mru_list as usize); + } + + /// Rebuild the Current Drawing list, and the previews kept for blocks + /// of other drawings and for whole drawings. + pub(crate) fn refresh_block_palette(&mut self) { + let i = self.active_tab; + let names = self.tabs[i].scene.custom_block_names(); + self.block_palette.cached_names = names.clone(); + self.block_palette.source_tab_id = Some(self.tabs[i].id); + self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch; + let doc = &self.tabs[i].scene.document; + let annotative = |name: &str| { + doc.block_records.get(name).is_some_and(|br| { + matches!( + doc.get_entity(br.block_entity_handle), + Some(codec::EntityType::Block(b)) if b.common.extended_data.records().iter().any(|r| r.application_name == "AcadAnnotative") + ) + }) + }; + let entries: Vec = names + .into_iter() + .map(|name| BlockEntry { + wires: self.tabs[i].scene.block_preview_wires(&name), + annotative: annotative(&name), + dynamic: false, + name, + }) + .collect(); + let here = self.current_drawing_path(); + for entry in &entries { + let key = format!("{here}|{}", entry.name.to_ascii_uppercase()); + self.block_palette.ref_wires.insert(key, entry.wires.clone()); + } + self.block_palette.blocks = entries; + let drawings: Vec = self + .block_palette + .recent + .iter() + .chain(&self.block_palette.favorites) + .filter(|r| r.drawing && !self.block_palette.file_thumbs.contains_key(&r.source)) + .map(|r| r.source.clone()) + .collect(); + for source in drawings { + let thumb = crate::io::thumbnail::read_handle(Path::new(&source)); + self.block_palette.file_thumbs.insert(source, thumb); + } + } + + /// Cheap per-update check: rebuild when the active drawing's definitions + /// changed, even when their names happen to stay the same. + pub(crate) fn refresh_block_palette_if_stale(&mut self) { + if !self.show_block_palette { + return; + } + let i = self.active_tab; + if self.block_palette.source_tab_id != Some(self.tabs[i].id) + || self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch + { + self.refresh_block_palette(); + } + } + + /// What `-INSERT`'s name step knows about the drawing's blocks. + pub(crate) fn block_catalog(&self) -> BlockCatalog { + let doc = &self.tabs[self.active_tab].scene.document; + let host = doc.header.insertion_units; + let mut user = Vec::new(); + let mut unnamed = 0; + let mut units = rustc_hash::FxHashMap::default(); + for br in doc.block_records.iter() { + if br.flags.is_xref || br.name.eq_ignore_ascii_case("*Model_Space") || br.name.to_ascii_uppercase().starts_with("*PAPER_SPACE") { + continue; + } + if br.name.starts_with('*') { + unnamed += 1; + continue; + } + let factor = crate::app::properties::insert_unit_scale(host, br.units).unwrap_or(1.0); + units.insert( + br.name.to_ascii_uppercase(), + format!( + "Units: {} Conversion:{:>10}", + crate::app::properties::insunits_name(br.units), + crate::app::properties::format_unit_factor(factor) + ), + ); + user.push(br.name.clone()); + } + user.sort_by_key(|n| n.to_ascii_uppercase()); + let search_path = std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(); + BlockCatalog { user, unnamed, units, search_path } + } +} diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 00d1b8b9e..943ab3d4a 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2,7 +2,6 @@ #![allow(unused_imports)] use super::util::*; -use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, @@ -373,21 +372,41 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven /// one new block in the active drawing. Returns the new block's name, or an /// error message. Nested block definitions are imported first so nested /// INSERTs render (AutoCAD's "inserting a drawing imports its block defs"). - fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result { + pub(in crate::app) fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result { + self.import_drawing_block(path, false) + } + + /// [`Self::import_file_as_block`]; with `redefine` a block already named + /// after the file takes the file's contents instead of a new name. + pub(super) fn import_drawing_block( + &mut self, + path: std::path::PathBuf, + redefine: bool, + ) -> Result { let doc = crate::io::load_file(&path).map_err(|e| e.to_string())?; let stem = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "Block".to_string()); - self.import_document_as_block(doc, stem) + self.import_document_block(doc, stem, redefine) } /// Define one block in the active drawing from a loaded `CadDocument`'s /// model-space entities (base = the file's model-space insertion base). + #[cfg(test)] fn import_document_as_block( &mut self, doc: codec::CadDocument, stem: String, + ) -> Result { + self.import_document_block(doc, stem, false) + } + + fn import_document_block( + &mut self, + doc: codec::CadDocument, + stem: String, + redefine: bool, ) -> Result { let i = self.active_tab; // Model-space block record handle (Layout object first, name fallback). @@ -441,7 +460,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven doc.header.model_space_insertion_base.y, doc.header.model_space_insertion_base.z, ); - let name = self.block_name_from_file(&stem); + let redefine = redefine + && self.tabs[i].scene.document.block_records.get(stem.trim()).is_some(); + let name = if redefine { + stem.trim().to_string() + } else { + self.block_name_from_file(&stem) + }; // Capture every table record needed by the top-level entities and their // nested block definitions. Importing only the definitions leaves // source-only layers, linetypes, and text/dimension styles dangling. @@ -513,74 +538,22 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .scene .define_block_raw(&def.name, def.base_point, def.entities); } - self.tabs[i] - .scene - .define_block_from_owned_entities(entities, &name, base)?; + if redefine { + self.tabs[i].scene.redefine_block_raw( + &name, + codec::types::Vector3::new(base.x, base.y, base.z), + entities, + ); + } else { + self.tabs[i] + .scene + .define_block_from_owned_entities(entities, &name, base)?; + } self.tabs[i].scene.populate_meshes_from_document(); self.tabs[i].dirty = true; Ok(name) } - pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task { - use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg}; - match m { - BlockPaletteMsg::Search(s) => { - self.block_palette.search = s; - iced::Task::none() - } - BlockPaletteMsg::CyclePreviewSize => { - self.block_palette.preview_size = - crate::ui::window::block_palette::cycle_preview_size( - self.block_palette.preview_size, - ); - iced::Task::none() - } - BlockPaletteMsg::Refresh => { - self.refresh_block_palette(); - iced::Task::none() - } - BlockPaletteMsg::PickFile => iced::Task::perform( - async { - let handle = rfd::AsyncFileDialog::new() - .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) - .add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) - .pick_file() - .await; - match handle { - Some(h) => Ok(crate::sys::handle_path(&h)), - None => Err("Cancelled".to_string()), - } - }, - |r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)), - ), - BlockPaletteMsg::FilePicked(Ok(path)) => { - match self.import_file_as_block(path) { - Ok(name) => { - self.command_line - .push_output(&crate::tf!("Inserting \"{name}\" from file.")); - self.refresh_block_palette(); - self.start_block_placement(&name); - } - Err(e) if e != "Cancelled" => { - self.command_line.push_error(&crate::tf!("INSERT FILE: {e}")); - } - Err(_) => {} - } - iced::Task::none() - } - BlockPaletteMsg::FilePicked(Err(e)) => { - if e != "Cancelled" { - self.command_line.push_error(&e); - } - iced::Task::none() - } - BlockPaletteMsg::Insert(name) => { - self.start_block_placement(&name); - iced::Task::none() - } - } - } - /// Dock chrome interaction (grab / pin / resize / hover / move) applied to /// whichever panel the message names. pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { @@ -754,57 +727,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .count() } - /// Start placing `name` through the INSERT command, skipping the name prompt. - fn start_block_placement(&mut self, name: &str) { - let i = self.active_tab; - let wires = self - .block_palette - .blocks - .iter() - .find(|b| b.name == name) - .map(|b| b.wires.clone()) - .unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name)); - use crate::modules::insert::insert_block::InsertBlockCommand; - let cmd = InsertBlockCommand::new_for_block(name.to_string(), wires, glam::Vec3::ZERO); - use crate::command::CadCommand; - self.reset_command_start_state(i); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - self.block_palette.placing = Some(name.to_string()); - } - - /// Rebuild the panel's block list + cached wires from the active drawing. - pub(crate) fn refresh_block_palette(&mut self) { - let i = self.active_tab; - let names = self.tabs[i].scene.custom_block_names(); - self.block_palette.cached_names = names.clone(); - self.block_palette.source_tab_id = Some(self.tabs[i].id); - self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch; - self.block_palette.blocks = names - .into_iter() - .map(|name| { - let wires = self.tabs[i].scene.block_preview_wires(&name); - crate::ui::window::block_palette::BlockEntry { name, wires } - }) - .collect(); - } - - /// Cheap per-update check: rebuild when the active drawing's definitions - /// changed, even when their names happen to stay the same. - pub(crate) fn refresh_block_palette_if_stale(&mut self) { - if !self.show_block_palette { - return; - } - let i = self.active_tab; - // `block_epoch` advances for every block-definition change, so it - // avoids re-scanning every block name on unrelated application updates. - if self.block_palette.source_tab_id != Some(self.tabs[i].id) - || self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch - { - self.refresh_block_palette(); - } - } - /// Rebuild the Reference Manager's entry list from the active drawing. /// Uses the tab's session sets (unloaded + stat baseline) so CLI and /// palette agree; writes fresh load-time mtimes back into the tab's stat @@ -1626,7 +1548,7 @@ mod tests { assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture")); app.start_block_placement(&name); let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running"); - assert_eq!(cmd.name(), "INSERT"); + assert_eq!(cmd.name(), "-INSERT"); assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture")); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bf54ca175..c271e998b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -734,6 +734,14 @@ impl OpenCADStudio { grid_beyond_limits: self.grid_beyond_limits, block_mru: self.block_mru.clone(), block_freq: self.block_freq.clone(), + block_recent: self.block_palette.recent.clone(), + block_favorites: self.block_palette.favorites.clone(), + block_libraries: self.block_palette.libraries.clone(), + block_palette_view: self.block_palette.view as u8, + block_insert: self.block_palette.options.clone(), + block_mru_list: self.block_mru_list, + block_redefine_mode: self.block_redefine_mode, + block_navigate: self.block_navigate.clone(), } } @@ -854,6 +862,15 @@ impl OpenCADStudio { .take(200) .map(|(k, v)| (k.clone(), *v)) .collect(); + self.block_mru_list = s.block_mru_list.min(100); + self.block_redefine_mode = s.block_redefine_mode.min(2); + self.block_navigate = s.block_navigate.clone(); + self.block_palette.recent = s.block_recent.clone(); + self.block_palette.favorites = s.block_favorites.clone(); + self.block_palette.libraries = s.block_libraries.clone(); + self.block_palette.view = crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view); + self.block_palette.set_options(s.block_insert.clone()); + self.trim_recent_blocks(); // Push restored display defaults onto every drawing tab that exists now. // Tabs created later pick them up at their construction site. for idx in 0..self.tabs.len() { @@ -863,6 +880,8 @@ impl OpenCADStudio { } pub(crate) fn record_block_insert(&mut self, name: &str) { + self.insname = name.to_string(); + self.note_recent_block(name); let key = name.to_ascii_uppercase(); *self.block_freq.entry(key).or_insert(0) += 1; self.block_mru.retain(|n| !n.eq_ignore_ascii_case(name)); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ce1d7ccd3..50d3fe13a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -92,6 +92,7 @@ fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> O mod command; mod context_menu; +mod blocks_palette; mod dialog; mod dynamic; mod file; @@ -5768,6 +5769,9 @@ impl OpenCADStudio { if self.tabs[self.active_tab].is_start { self.ribbon.close_dropdown(); } else { + if id == crate::modules::insert::insert_block::GALLERY_ID { + self.refresh_block_palette(); + } self.ribbon.toggle_dropdown(&id); } Task::none() diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c12f3646d..7e7867f76 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3356,6 +3356,7 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; + self.block_palette.pressed = None; if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down); @@ -3724,6 +3725,10 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_release(&mut self) -> Task { let i = self.active_tab; + // A block dragged from the Blocks palette lands here. + if let Some(item) = self.take_block_drop() { + return self.drop_block(item); + } if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6399320d8..8a357f0b9 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2316,7 +2316,17 @@ bg={bg_ms:.1}ms n={view_count}" } else { Vec::new() }; - let dropdown_layer: Element<'_, Message> = self + let gallery_open = open_dropdown == Some(crate::modules::insert::insert_block::GALLERY_ID) + && !self.tabs[self.active_tab].is_start; + let dropdown_layer: Element<'_, Message> = if gallery_open { + self.ribbon.place_dropdown( + crate::modules::insert::insert_block::GALLERY_ID, + crate::ui::window::block_palette::gallery(&self.block_palette), + crate::ui::window::block_palette::GALLERY_W, + self.win_size.0, + ) + } else { + self .ribbon .dropdown_overlay( &undo_labels, @@ -2325,7 +2335,8 @@ bg={bg_ms:.1}ms n={view_count}" self.tabs[self.active_tab].is_start, &self.recent_colors, ) - .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()); + .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()) + }; let snap_override_layer: Element<'_, Message> = if let Some(pos) = self.snap_override_popup { diff --git a/src/command.rs b/src/command.rs index 427c15a1b..885a4ebf9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1888,6 +1888,12 @@ pub enum CmdResult { ReportError(String), /// Reports an error and ends the command (`Lines are parallel.`). CancelWithMessage(String), + /// Explode this block reference and commit its pieces; end the command + /// unless `keep_going` (repeated placement). + CommitExplodedInsert { + insert: EntityType, + keep_going: bool, + }, /// Print a measurement result, clear the current selection, and keep the command active. ReportMeasurementAndDeselect(String), /// Clear the current selection and keep the command active at its updated step. @@ -2926,6 +2932,12 @@ pub trait CadCommand: Send { None } + /// After the host commits an ATTREQ insert: keep the command running + /// (repeated placement) instead of ending it. + fn attreq_continue(&self) -> bool { + false + } + /// Returns the INSERT entity built so far (pending attr fill) if this is an /// ATTREQ-aware INSERT command waiting for attdef injection. /// Called by the host after `AttreqNeeded` to commit the completed Insert. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 2c8c5156d..a4cbb463e 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -163,6 +163,40 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Requires a positive number." => Some(("insert", "pcs-positive")), "Requires a positive integer." => Some(("insert", "pcs-positive-int")), "Traces the points on the kept side of a live section as lines." => Some(("insert", "pcs-help")), + "Add to Favorites" => Some(("insert", "bp-add-favorite")), + "Annotative / Dynamic" => Some(("insert", "bp-annotative-dynamic")), + "Auto-Placement" => Some(("insert", "bp-auto-placement")), + "Blocks from Libraries..." => Some(("insert", "bp-gallery-libraries")), + "Choose a library folder with …" => Some(("insert", "bp-choose-library")), + "Current Drawing" => Some(("insert", "bp-tab-current")), + "Current Drawing Blocks" => Some(("insert", "bp-heading-current")), + "Extra Large Icons" => Some(("insert", "bp-view-xl")), + "Favorite Blocks" => Some(("insert", "bp-heading-favorites")), + "Favorite Blocks..." => Some(("insert", "bp-gallery-favorites")), + "Favorites" => Some(("insert", "bp-tab-favorites")), + "Filter... (* ? wildcards)" => Some(("insert", "bp-filter")), + "Icon" => Some(("insert", "bp-icon")), + "Insertion Point" => Some(("insert", "bp-insertion-point")), + "Large Icons" => Some(("insert", "bp-view-large")), + "Libraries" => Some(("insert", "bp-tab-libraries")), + "Library Blocks" => Some(("insert", "bp-heading-libraries")), + "Medium Icons" => Some(("insert", "bp-view-medium")), + "Recent" => Some(("insert", "bp-tab-recent")), + "Recent Blocks" => Some(("insert", "bp-heading-recent")), + "Recent Blocks..." => Some(("insert", "bp-gallery-recent")), + "Redefine" => Some(("insert", "bp-redefine")), + "Remove Block" => Some(("insert", "bp-remove-block")), + "Remove from Favorites" => Some(("insert", "bp-remove-favorite")), + "Remove from List" => Some(("insert", "bp-remove-list")), + "Repeat Placement" => Some(("insert", "bp-repeat-placement")), + "Select a Block Library Folder" => Some(("insert", "bp-select-library")), + "Small Icons" => Some(("insert", "bp-view-small")), + "specified on screen" => Some(("insert", "bp-on-screen")), + "Block \"{}\" not found." => Some(("insert", "bp-block-not-found")), + "Block \"{name}\" redefined." => Some(("insert", "bp-block-redefined")), + "Block \"{name}\" has no reference in the drawing to edit." => Some(("insert", "bp-block-no-reference")), + "Block \"{name}\" is referenced in the drawing and cannot be removed." => Some(("insert", "bp-block-referenced")), + "Block \"{name}\" removed." => Some(("insert", "bp-block-removed")), "Stylization" => Some(("properties", "stylization")), "Color scheme" => Some(("properties", "color-scheme")), "Scan Colors" => Some(("properties", "scan-colors")), diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index a9db09f43..d8092f026 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -202,7 +202,7 @@ impl CadModule for DrawModule { title: "Block", tools: vec![ RibbonItem::LargeTool(create_block::tool()), - RibbonItem::LargeTool(insert_block::tool()), + insert_block::gallery(), ], }, RibbonGroup { diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index 2bf879675..c504cab75 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -1,27 +1,98 @@ +//! Block insertion: `-INSERT` on the command line, a click on the Blocks +//! palette, and paste-as-block. +//! +//! `-INSERT` follows the reference's prompts: +//! Enter block name or [?] : +//! Units: Feet Conversion: 1.0000 +//! Specify insertion point or [Basepoint/Scale/X/Y/Z/Rotate/Explode/REpeat]: +//! Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>: +//! Enter Y scale factor : +//! Specify rotation angle <0>: +//! A palette click asks only what its options leave open, with the shorter +//! option list `[Basepoint/Scale/X/Y/Z/Rotate]`. + use codec::entities::{AttributeDefinition, AttributeEntity, Entity, Insert}; use codec::types::Vector3; use codec::EntityType; use glam::{DVec3, Vec3}; use crate::t; -use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::app::settings::BlockInsertOptions; +use crate::command::{CadCommand, CmdResult, DynField, InputKind, WorkingPlane}; +use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; -pub fn tool() -> ToolDef { - ToolDef { - id: "INSERT", +/// The ribbon's Insert button: its face opens the Blocks palette, its arrow +/// the gallery of the drawing's blocks. +pub const GALLERY_ID: &str = "INSERT_GALLERY"; +pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")); +pub const GALLERY_ITEMS: &[(&str, &str, IconKind)] = &[("INSERT", "Insert Block", ICON)]; + +pub fn gallery() -> crate::modules::RibbonItem { + crate::modules::RibbonItem::LargeDropdown { + id: GALLERY_ID, label: "Insert Block", - icon: IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")), - event: ModuleEvent::Command("INSERT".to_string()), + icon: ICON, + items: GALLERY_ITEMS.to_vec(), + default: "INSERT", } } +/// What the drawing knows about its blocks, for `-INSERT`'s name step. +#[derive(Clone, Default)] +pub struct BlockCatalog { + /// Named (user) blocks, sorted. + pub user: Vec, + /// How many anonymous (`*`) blocks the drawing holds. + pub unnamed: usize, + /// Each user block's `Units: … Conversion: …` line. + pub units: rustc_hash::FxHashMap, + /// The folder `-INSERT` reports as its search path. + pub search_path: String, +} + +#[derive(Clone, Copy, PartialEq)] +enum Mode { + /// `-INSERT`. + Classic, + /// A Blocks palette click. + Palette, + /// Paste-as-block: only the drop point. + Paste, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Preset { + Base, + Scale, + X, + Y, + Z, + Rotate, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Ask { + Explode, + Repeat, + RepeatScale, + RepeatRotate, +} + enum Step { Name, - Point { - name: String, - }, + ListPattern, + Point, + Preset(Preset), + YesNo(Ask), + ScaleX, + Corner, + XyzX, + XyzY, + XyzZ, + ScaleY, + Uniform, + Rotation, FillAttr { attdefs: Vec, idx: usize, @@ -29,80 +100,299 @@ enum Step { }, } -/// Which numeric value the insertion-point step is currently waiting for after -/// a Scale / Rotate keyword. -#[derive(Clone, Copy)] -enum AwaitKind { - Scale, - Rotation, -} - pub struct InsertBlockCommand { + mode: Mode, picker: crate::modules::insert::picker::BlockPicker, + catalog: BlockCatalog, + default_name: String, step: Step, - /// Uniform X/Y scale applied to the placed block (default 1). - x_scale: f64, - y_scale: f64, - /// Rotation applied to the placed block, in radians (default 0). - rotation_rad: f64, - /// Set while a Scale/Rotate value is being typed at the insertion step. - awaiting: Option, - /// Pending Insert entity stored while attr-filling is in progress. + name: String, + /// Block preview geometry, in block coordinates. + preview: Vec, + /// The point of the block that lands on the insertion point (Basepoint). + base: DVec3, + /// Scales / rotation fixed before the point (options, palette fields). + preset_scale: [Option; 3], + preset_rotation: Option, + /// Palette: the scale is asked after the point, uniformly. + ask_uniform: bool, + explode: bool, + repeat: bool, + repeat_scale: bool, + repeat_rotation: bool, + placed: usize, + point: Option, + scale: [f64; 3], + rotation: f64, pending_insert: Option, - /// Optional drag preview: the block's wire geometry plus the base point it - /// is measured from, so `on_preview_wires` can rubber-band it to the - /// cursor. Set by paste-as-block; empty for a plain INSERT. - preview: Option<(Vec, Vec3)>, plane: WorkingPlane, } impl InsertBlockCommand { - /// Construct with explicit usage ranking. `usage` maps uppercase block name → (frequency, MRU position). - /// `cliprompt_lines` directly controls suggestion count (relation per spec). - pub fn new_with_usage( + fn base_state(mode: Mode, picker: crate::modules::insert::picker::BlockPicker) -> Self { + Self { + mode, + picker, + catalog: BlockCatalog::default(), + default_name: String::new(), + step: Step::Name, + name: String::new(), + preview: Vec::new(), + base: DVec3::ZERO, + preset_scale: [None; 3], + preset_rotation: None, + ask_uniform: false, + explode: false, + repeat: false, + repeat_scale: false, + repeat_rotation: false, + placed: 0, + point: None, + scale: [1.0; 3], + rotation: 0.0, + pending_insert: None, + plane: WorkingPlane::default(), + } + } + + /// `-INSERT`: the name step first. `default_name` is INSNAME. + pub fn classic( available: Vec, usage_rank: rustc_hash::FxHashMap, cliprompt_lines: u8, + catalog: BlockCatalog, + default_name: String, ) -> Self { let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); - Self { - picker, - step: Step::Name, - x_scale: 1.0, - y_scale: 1.0, - rotation_rad: 0.0, - awaiting: None, - pending_insert: None, - preview: None, - plane: WorkingPlane::default(), - } + let mut command = Self::base_state(Mode::Classic, picker); + command.catalog = catalog; + command.default_name = default_name; + command } - /// Start the command already locked to `name`, skipping the name prompt and - /// going straight to "specify insertion point". `preview_wires` (measured - /// from `base`) rubber-band under the cursor. Used by paste-as-block, which - /// has just defined the block and only needs the drop point. + /// `-INSERT` already given a block (a drawing just inserted as a block). + pub fn classic_for_block(name: String, preview: Vec, units_line: String) -> Self { + let mut command = Self::base_state(Mode::Classic, Self::single_picker(&name)); + command.catalog.units.insert(name.to_ascii_uppercase(), units_line); + command.name = name; + command.preview = preview; + command.step = Step::Point; + command + } + + /// A Blocks palette click with the palette's options. + pub fn palette(name: String, preview: Vec, options: &BlockInsertOptions) -> Self { + let mut command = Self::base_state(Mode::Palette, Self::single_picker(&name)); + command.name = name; + command.preview = preview; + if !options.scale { + let x = options.x; + command.preset_scale = if options.uniform { + [Some(x); 3] + } else { + [Some(options.x), Some(options.y), Some(options.z)] + }; + } else { + command.ask_uniform = options.uniform; + } + if !options.rotation { + command.preset_rotation = Some(options.angle.to_radians()); + } + command.explode = options.explode; + command.repeat = options.repeat; + command.repeat_scale = options.scale; + command.repeat_rotation = options.rotation; + command.step = Step::Point; + if !options.insertion_point { + command.point = Some(DVec3::ZERO); + } + command + } + + /// Paste-as-block: the block is placed at the picked point. pub fn new_for_block(name: String, preview_wires: Vec, base: Vec3) -> Self { - // Minimal picker for the locked-name path; not used for Name step. - let picker = crate::modules::insert::picker::BlockPicker::new( - vec![name.clone()], + let mut command = Self::base_state(Mode::Paste, Self::single_picker(&name)); + command.name = name; + command.preview = preview_wires; + command.base = base.as_dvec3(); + command.preset_scale = [Some(1.0); 3]; + command.preset_rotation = Some(0.0); + command.step = Step::Point; + command + } + + fn single_picker(name: &str) -> crate::modules::insert::picker::BlockPicker { + crate::modules::insert::picker::BlockPicker::new( + vec![name.to_string()], rustc_hash::FxHashMap::default(), 0, - ); - Self { - picker, - step: Step::Point { name }, - x_scale: 1.0, - y_scale: 1.0, - rotation_rad: 0.0, - awaiting: None, - pending_insert: None, - preview: Some((preview_wires, base)), - plane: WorkingPlane::default(), + ) + } + + /// A block dropped from the palette: it goes in at `point` (world). + pub fn set_point(&mut self, point: DVec3) { + self.point = Some(point); + } + + /// A palette click with the insertion point turned off and nothing to + /// ask: the block goes in at once. The host applies this result before + /// showing a prompt. + pub fn initial(&mut self) -> Option { + if self.point.is_some() && matches!(self.step, Step::Point) { + return Some(self.after_point()); + } + None + } + + fn options_text(&self) -> &'static str { + match self.mode { + Mode::Classic => "[Basepoint/Scale/X/Y/Z/Rotate/Explode/REpeat]", + _ => "[Basepoint/Scale/X/Y/Z/Rotate]", } } + /// After the insertion point: ask what is still open, else place. + fn after_point(&mut self) -> CmdResult { + if self.preset_scale[0].is_none() { + self.step = if self.ask_uniform { Step::Uniform } else { Step::ScaleX }; + return CmdResult::NeedPoint; + } + self.scale = [ + self.preset_scale[0].unwrap_or(1.0), + self.preset_scale[1].unwrap_or(1.0), + self.preset_scale[2].unwrap_or(1.0), + ]; + self.after_scale() + } + + fn after_scale(&mut self) -> CmdResult { + match self.preset_rotation { + Some(r) => { + self.rotation = r; + self.place() + } + None => { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + } + + /// Build the reference at the chosen point and hand it over. + fn place(&mut self) -> CmdResult { + let Some(point) = self.point.take() else { + return CmdResult::NeedPoint; + }; + let local = self.plane.to_local(point); + let offset = self.local_offset(self.base); + let mut ins = Insert::new( + self.name.clone(), + Vector3::new(local.x - offset.x, local.y - offset.y, local.z - offset.z), + ); + ins.set_x_scale(self.scale[0]); + ins.set_y_scale(self.scale[1]); + ins.set_z_scale(self.scale[2]); + ins.rotation = self.rotation; + if !self.plane.is_identity() { + ins.apply_transform(&self.plane.to_world_transform()); + } + self.placed += 1; + // Repeating: later blocks keep the first one's scale / rotation + // unless each block is to be asked. + if self.repeat { + if !self.repeat_scale { + self.preset_scale = self.scale.map(Some); + } + if !self.repeat_rotation { + self.preset_rotation = Some(self.rotation); + } + self.step = Step::Point; + } + if self.explode { + return CmdResult::CommitExplodedInsert { + insert: EntityType::Insert(ins), + keep_going: self.repeat, + }; + } + let block_name = self.name.clone(); + self.pending_insert = Some(ins); + CmdResult::AttreqNeeded { block_name } + } + + /// The scaled, rotated block-space offset of `p`. + fn local_offset(&self, p: DVec3) -> DVec3 { + let s = DVec3::new(p.x * self.scale[0], p.y * self.scale[1], p.z * self.scale[2]); + let (sin, cos) = self.rotation.sin_cos(); + DVec3::new(s.x * cos - s.y * sin, s.x * sin + s.y * cos, s.z) + } + + fn number(text: &str) -> Option { + let t = text.trim().replace(',', "."); + t.parse::().ok().filter(|v| v.is_finite()) + } + + fn yes_no(text: &str, default: bool) -> Option { + match text.trim().to_ascii_uppercase().as_str() { + "" => Some(default), + "Y" | "YES" => Some(true), + "N" | "NO" => Some(false), + _ => None, + } + } + + /// `-INSERT ?`: the reference's listing. + fn listing(&self, pattern: &str) -> String { + let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() }; + let mut out = String::from("Defined blocks."); + let mut shown = 0; + for name in &self.catalog.user { + if pattern.split(',').any(|p| crate::io::xref_model::wildcard_match(name, p.trim())) { + out.push_str(&format!("\n \"{name}\"")); + shown += 1; + } + } + let _ = shown; + out.push_str(&format!( + "\n\nUser Unnamed\nBlocks Blocks\n{:>5}{:>9}", + self.catalog.user.len(), + self.catalog.unnamed + )); + out + } + + /// The block name chosen at the name step. + fn choose(&mut self, name: String) -> CmdResult { + let units = self + .catalog + .units + .get(&name.to_ascii_uppercase()) + .cloned() + .unwrap_or_default(); + self.name = name; + self.step = Step::Point; + if units.is_empty() { + CmdResult::NeedPoint + } else { + CmdResult::ReportMeasurement(units) + } + } + + fn not_found(&self, name: &str) -> CmdResult { + let file = if name.to_ascii_lowercase().ends_with(".dwg") { + name.to_string() + } else { + format!("{name}.dwg") + }; + let path = std::path::Path::new(&file); + if path.is_file() { + return CmdResult::Dispatch(format!("_-INSERTFILE {}", path.display())); + } + CmdResult::CancelWithMessage(format!( + "\"{file}\": Can't find file in search path:\n {} (current directory)\n*Invalid*", + self.catalog.search_path + )) + } } impl CadCommand for InsertBlockCommand { @@ -111,55 +401,59 @@ impl CadCommand for InsertBlockCommand { } fn name(&self) -> &'static str { - "INSERT" + match self.mode { + Mode::Paste => "INSERT", + _ => "-INSERT", + } } fn prompt(&self) -> String { match &self.step { Step::Name => { - if self.picker.is_empty() { - return t!("INSERT Enter block name:").into_owned(); - } - let needle = self.picker.needle(); - let filtered = self.picker.filtered(); - if !needle.is_empty() && filtered.is_empty() { - return t!( - "INSERT No matching blocks for \"%{needle}\"", - needle = needle - ) - .into_owned(); - } - if needle.is_empty() { - let total = self.picker.total(); - let shown = filtered.len(); - if total <= shown { - t!("INSERT Enter block name:").into_owned() - } else { - t!( - "INSERT Enter block name: [%{shown} of %{total} — type to search]", - shown = shown, - total = total - ) - .into_owned() - } + if self.default_name.is_empty() { + "Enter block name or [?]:".to_string() } else { - t!( - "INSERT Enter block name: \"%{needle}\" [%{shown} matches]", - needle = needle, - shown = filtered.len() - ) - .into_owned() + format!("Enter block name or [?] <{}>:", self.default_name) } } - Step::Point { name } => match self.awaiting { - Some(AwaitKind::Scale) => t!("INSERT Specify scale factor <1>:").into_owned(), - Some(AwaitKind::Rotation) => t!("INSERT Specify rotation angle <0>:").into_owned(), - None => t!( + Step::ListPattern => "Enter block(s) to list <*>:".to_string(), + Step::Point => match self.mode { + Mode::Paste => t!( "INSERT Specify insertion point for \"%{name}\" [Scale/Rotate]:", - name = name + name = self.name ) .into_owned(), + _ => format!("Specify insertion point or {}:", self.options_text()), }, + Step::Preset(Preset::Base) => "Specify base point:".to_string(), + Step::Preset(Preset::Scale) => match self.mode { + Mode::Paste => t!("INSERT Specify scale factor <1>:").into_owned(), + _ => "Specify scale factor for XYZ axes <1>:".to_string(), + }, + Step::Preset(Preset::X) => "Specify X scale factor <1>:".to_string(), + Step::Preset(Preset::Y) => "Specify Y scale factor <1>:".to_string(), + Step::Preset(Preset::Z) => "Specify Z scale factor <1>:".to_string(), + Step::Preset(Preset::Rotate) => match self.mode { + Mode::Paste => t!("INSERT Specify rotation angle <0>:").into_owned(), + _ => "Specify rotation angle <0>:".to_string(), + }, + Step::YesNo(Ask::Explode) => "Explode block [Yes/No] :".to_string(), + Step::YesNo(Ask::Repeat) => "Repeat insertion of block [Yes/No] :".to_string(), + Step::YesNo(Ask::RepeatScale) => { + "Enter a scale factor for each block [Yes/No] ".to_string() + } + Step::YesNo(Ask::RepeatRotate) => { + "Enter a rotation angle for each block [Yes/No] ".to_string() + } + Step::ScaleX => { + "Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>:".to_string() + } + Step::Corner => "Specify opposite corner:".to_string(), + Step::XyzX => "Specify X scale factor or [Corner] <1>:".to_string(), + Step::XyzY | Step::ScaleY => "Enter Y scale factor :".to_string(), + Step::XyzZ => "Specify Z scale factor or :".to_string(), + Step::Uniform => "Specify scale factor for XYZ axes <1>:".to_string(), + Step::Rotation => "Specify rotation angle <0>:".to_string(), Step::FillAttr { attdefs, idx, .. } => { if let Some(ad) = attdefs.get(*idx) { let default_hint = if ad.default_value.is_empty() { @@ -186,24 +480,40 @@ impl CadCommand for InsertBlockCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - match &self.step { - Step::Name => CmdResult::NeedPoint, - Step::Point { name } => { - let point = self.plane.to_local(pt); - let mut ins = Insert::new( - name.clone(), - Vector3::new(point.x, point.y, point.z), - ); - ins.set_x_scale(self.x_scale); - ins.set_y_scale(self.y_scale); - ins.rotation = self.rotation_rad; - ins.apply_transform(&self.plane.to_world_transform()); - let block_name = name.clone(); - self.pending_insert = Some(ins); - // Signal the host to check for attdefs. - CmdResult::AttreqNeeded { block_name } + match self.step { + Step::Point => { + self.point = Some(pt); + self.after_point() } - Step::FillAttr { .. } => CmdResult::NeedPoint, + Step::Preset(Preset::Base) => { + self.base = self.plane.to_local(pt); + self.step = Step::Point; + CmdResult::NeedPoint + } + Step::ScaleX | Step::Corner | Step::XyzX => { + let Some(point) = self.point else { + return CmdResult::NeedPoint; + }; + let d = self.plane.to_local(pt) - self.plane.to_local(point); + if d.x.abs() < 1e-12 || d.y.abs() < 1e-12 { + return CmdResult::ReportError("Value must be nonzero.".to_string()); + } + self.scale = [d.x, d.y, d.x]; + self.after_scale() + } + Step::Rotation => { + let Some(point) = self.point else { + return CmdResult::NeedPoint; + }; + match self.plane.angle(point, pt) { + Some(angle) => { + self.rotation = angle; + self.place() + } + None => CmdResult::NeedPoint, + } + } + _ => CmdResult::NeedPoint, } } @@ -223,10 +533,8 @@ impl CadCommand for InsertBlockCommand { if !matches!(self.step, Step::Name) { return false; } - // Performance: only recompute if needle actually changed; picker - // uses upper/lower caches and partial sort so per-keystroke is <0.2ms. let needle = input.trim(); - if needle == self.picker.needle() { + if needle == self.picker.needle() || needle == "?" { return false; } self.picker.set_needle(needle.to_string()); @@ -234,114 +542,273 @@ impl CadCommand for InsertBlockCommand { } fn on_enter(&mut self) -> CmdResult { - match &self.step { - // A bare Enter while a scale/rotation value is awaited keeps the - // current default instead of cancelling the command. - Step::Point { .. } if self.awaiting.is_some() => { - self.awaiting = None; + match self.step { + Step::Name => { + if self.default_name.is_empty() { + CmdResult::Cancel + } else { + let name = self.default_name.clone(); + match self.picker.contains_name(&name) { + Some(canonical) => self.choose(canonical), + None => self.not_found(&name), + } + } + } + Step::ListPattern => CmdResult::Measurement(self.listing("*")), + Step::Point => { + if self.repeat && self.placed > 0 { + CmdResult::Cancel + } else if self.mode == Mode::Paste { + CmdResult::Cancel + } else { + CmdResult::ReportError("Point or option keyword required.".to_string()) + } + } + Step::Preset(_) => { + self.step = Step::Point; CmdResult::NeedPoint } - Step::Name | Step::Point { .. } => CmdResult::Cancel, - Step::FillAttr { .. } => { - // Treat Enter as accepting the default. - self.accept_attr_value("") + Step::YesNo(ask) => self.answer(ask, None), + Step::ScaleX | Step::XyzX => { + self.scale[0] = 1.0; + self.step = if matches!(self.step, Step::XyzX) { Step::XyzY } else { Step::ScaleY }; + CmdResult::NeedPoint } + Step::XyzY => { + self.scale[1] = self.scale[0]; + self.step = Step::XyzZ; + CmdResult::NeedPoint + } + Step::XyzZ => { + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::ScaleY => { + self.scale[1] = self.scale[0]; + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::Uniform => { + self.scale = [1.0; 3]; + self.after_scale() + } + Step::Rotation => { + self.rotation = 0.0; + self.place() + } + Step::Corner => CmdResult::NeedPoint, + Step::FillAttr { .. } => self.accept_attr_value(""), } } fn input_kind(&self) -> InputKind { match self.step { Step::FillAttr { .. } => InputKind::FreeText, - Step::Name | Step::Point { .. } => InputKind::SingleToken, + Step::Name | Step::ListPattern | Step::YesNo(_) => InputKind::SingleToken, + Step::Point | Step::Preset(Preset::Base) | Step::Corner => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Rotation | Step::Preset(Preset::Rotate) => DynField::Angle, + Step::ScaleX + | Step::ScaleY + | Step::XyzX + | Step::XyzY + | Step::XyzZ + | Step::Uniform + | Step::Preset(_) => DynField::Scalar, + _ => DynField::Point, } } fn point_step_accepts_keywords(&self) -> bool { - // The insertion-point step also takes Scale / Rotate keywords while - // still accepting a point pick. Once a value is being typed (awaiting), - // route the whole number through `on_text_input` instead. - matches!(self.step, Step::Point { .. }) && self.awaiting.is_none() + matches!(self.step, Step::Point) } fn on_text_input(&mut self, text: &str) -> Option { - match &self.step { + let t = text.trim(); + let up = t.to_ascii_uppercase(); + Some(match self.step { Step::Name => { - let name = text.trim(); - // Empty input: reset needle to show default ranked list (2.1). - // Consumed so prompt+buttons refresh, not stale filter. - if name.is_empty() { + if t == "?" { + self.step = Step::ListPattern; + return Some(CmdResult::NeedPoint); + } + if t.is_empty() { self.picker.set_needle(String::new()); return Some(CmdResult::NeedPoint); } - // Exact block name (case-insensitive) → accept and go to point step. - // This path is used both for typed exact names and for CmdOption - // button clicks (Message::CommandOptionPick feeds the keyword through - // on_text_input). Returning NeedPoint keeps the prompt updated. - if let Some(canonical) = self.picker.contains_name(name) { - self.step = Step::Point { name: canonical }; - return Some(CmdResult::NeedPoint); + match self.picker.contains_name(t) { + Some(canonical) => self.choose(canonical), + None => self.not_found(t), } - // Not an exact match → treat as incremental search needle. - // Update filter and re-render prompt + option buttons. Must return - // Some(NeedPoint) (consumed) not None, otherwise the driver would - // offer the same text to the command a second time. - self.picker.set_needle(name.to_string()); - Some(CmdResult::NeedPoint) } - Step::FillAttr { .. } => Some(self.accept_attr_value(text)), - Step::Point { .. } => { - // Typing the value awaited after a Scale / Rotate keyword. The - // two read differently: a scale is a plain multiplier with no - // unit to write it in, while a rotation is an angle and takes - // whichever convention the drawing is set to. - if let Some(kind) = self.awaiting { - match kind { - AwaitKind::Scale => { - if let Ok(v) = text.trim().parse::() { - if v != 0.0 { - self.x_scale = v; - self.y_scale = v; - } - } - } - AwaitKind::Rotation => { - if let Some(v) = crate::entities::common::parse_typed_angle(text) { - self.rotation_rad = v; - } + Step::ListPattern => CmdResult::Measurement(self.listing(t)), + Step::Point => match up.as_str() { + "B" | "BASEPOINT" => { + self.step = Step::Preset(Preset::Base); + CmdResult::NeedPoint + } + "S" | "SCALE" => { + self.step = Step::Preset(Preset::Scale); + CmdResult::NeedPoint + } + "X" => { + self.step = Step::Preset(Preset::X); + CmdResult::NeedPoint + } + "Y" => { + self.step = Step::Preset(Preset::Y); + CmdResult::NeedPoint + } + "Z" => { + self.step = Step::Preset(Preset::Z); + CmdResult::NeedPoint + } + "R" | "ROTATE" => { + self.step = Step::Preset(Preset::Rotate); + CmdResult::NeedPoint + } + "E" | "EXPLODE" if self.mode == Mode::Classic => { + self.step = Step::YesNo(Ask::Explode); + CmdResult::NeedPoint + } + "RE" | "REPEAT" if self.mode == Mode::Classic => { + self.step = Step::YesNo(Ask::Repeat); + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + Step::Preset(kind) => { + if kind == Preset::Rotate { + match crate::entities::common::parse_typed_angle(t) { + Some(angle) => self.preset_rotation = Some(angle), + None => { + return Some(CmdResult::ReportError( + "Requires valid numeric angle or second point.".to_string(), + )) } } - self.awaiting = None; - return Some(CmdResult::NeedPoint); - } - match text.trim().to_uppercase().as_str() { - "S" | "SCALE" => { - self.awaiting = Some(AwaitKind::Scale); - Some(CmdResult::NeedPoint) + } else { + match Self::number(t) { + Some(v) if v != 0.0 => match kind { + Preset::Scale => self.preset_scale = [Some(v); 3], + Preset::X => self.preset_scale[0] = Some(v), + Preset::Y => self.preset_scale[1] = Some(v), + Preset::Z => self.preset_scale[2] = Some(v), + _ => {} + }, + Some(_) => { + return Some(CmdResult::ReportError("Value must be nonzero.".to_string())) + } + None => { + return Some(CmdResult::ReportError( + "Requires numeric value.".to_string(), + )) + } } - "R" | "ROTATE" => { - self.awaiting = Some(AwaitKind::Rotation); - Some(CmdResult::NeedPoint) + // A single axis given: the others stay at 1. + if self.preset_scale.iter().any(Option::is_some) { + for axis in &mut self.preset_scale { + axis.get_or_insert(1.0); + } } - _ => None, } + self.step = Step::Point; + CmdResult::NeedPoint } - } + Step::YesNo(ask) => self.answer(ask, Some(t)), + Step::ScaleX | Step::XyzX => match up.as_str() { + "C" | "CORNER" => { + self.step = Step::Corner; + CmdResult::NeedPoint + } + "XYZ" if matches!(self.step, Step::ScaleX) => { + self.step = Step::XyzX; + CmdResult::NeedPoint + } + _ => match Self::number(t) { + Some(v) if v != 0.0 => { + self.scale[0] = v; + self.step = if matches!(self.step, Step::XyzX) { Step::XyzY } else { Step::ScaleY }; + CmdResult::NeedPoint + } + Some(_) => CmdResult::ReportError("Value must be nonzero.".to_string()), + None => CmdResult::ReportError("Requires numeric value.".to_string()), + }, + }, + Step::ScaleY | Step::XyzY | Step::XyzZ | Step::Uniform => match Self::number(t) { + Some(v) if v != 0.0 => match self.step { + Step::ScaleY => { + self.scale[1] = v; + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::XyzY => { + self.scale[1] = v; + self.step = Step::XyzZ; + CmdResult::NeedPoint + } + Step::XyzZ => { + self.scale[2] = v; + self.after_scale() + } + _ => { + self.scale = [v; 3]; + self.after_scale() + } + }, + Some(_) => CmdResult::ReportError("Value must be nonzero.".to_string()), + None => CmdResult::ReportError("Requires numeric value.".to_string()), + }, + Step::Rotation => match crate::entities::common::parse_typed_angle(t) { + Some(angle) => { + self.rotation = angle; + self.place() + } + None => CmdResult::ReportError( + "Requires valid numeric angle or second point.".to_string(), + ), + }, + Step::Corner => CmdResult::NeedPoint, + Step::FillAttr { .. } => self.accept_attr_value(text), + }) } - fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let pt = pt.as_vec3(); - match (&self.step, &self.preview) { - (Step::Point { .. }, Some((wires, base))) => { - let delta = pt - *base; - wires.iter().map(|w| w.translated(delta)).collect() - } - _ => vec![], + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if self.preview.is_empty() || !matches!(self.step, Step::Point) { + return Vec::new(); } + let scale = [ + self.preset_scale[0].unwrap_or(1.0), + self.preset_scale[1].unwrap_or(1.0), + self.preset_scale[2].unwrap_or(1.0), + ]; + let (sin, cos) = self.preset_rotation.unwrap_or(0.0).sin_cos(); + let base = self.base; + let origin = self.plane.to_local(pt); + let plane = self.plane; + self.preview + .iter() + .map(|w| { + w.mapped(|p| { + let d = p - base; + let s = DVec3::new(d.x * scale[0], d.y * scale[1], d.z * scale[2]); + let r = DVec3::new(s.x * cos - s.y * sin, s.x * sin + s.y * cos, s.z); + plane.to_world(origin + r) + }) + }) + .collect() } - fn attreq_set_attdefs( - &mut self, - attdefs: Vec, - ) -> Option { + fn attreq_continue(&self) -> bool { + self.repeat + } + + fn attreq_set_attdefs(&mut self, attdefs: Vec) -> Option { self.step = Step::FillAttr { attdefs, idx: 0, @@ -351,13 +818,37 @@ impl CadCommand for InsertBlockCommand { } fn attreq_take_insert(&mut self) -> Option { - self.pending_insert - .take() - .map(|ins| EntityType::Insert(ins)) + self.pending_insert.take().map(EntityType::Insert) } } impl InsertBlockCommand { + fn answer(&mut self, ask: Ask, text: Option<&str>) -> CmdResult { + let default = !matches!(ask, Ask::RepeatScale | Ask::RepeatRotate); + let Some(yes) = Self::yes_no(text.unwrap_or(""), default) else { + return CmdResult::ReportError("Invalid option keyword.".to_string()); + }; + self.step = match ask { + Ask::Explode => { + self.explode = yes; + Step::Point + } + Ask::Repeat => { + self.repeat = yes; + if yes { Step::YesNo(Ask::RepeatScale) } else { Step::Point } + } + Ask::RepeatScale => { + self.repeat_scale = yes; + Step::YesNo(Ask::RepeatRotate) + } + Ask::RepeatRotate => { + self.repeat_rotation = yes; + Step::Point + } + }; + CmdResult::NeedPoint + } + fn accept_attr_value(&mut self, text: &str) -> CmdResult { let (attdef_idx, default) = match &self.step { Step::FillAttr { attdefs, idx, .. } => { @@ -368,13 +859,11 @@ impl InsertBlockCommand { } _ => return CmdResult::Cancel, }; - let value = if text.trim().is_empty() { default } else { text.trim().to_string() }; - if let Step::FillAttr { ref mut values, ref mut idx, @@ -384,8 +873,11 @@ impl InsertBlockCommand { values.push((attdef_idx, value)); *idx = attdef_idx + 1; } - match self.advance_automatic_attributes() { + Some(entity) if self.repeat => { + self.step = Step::Point; + CmdResult::CommitEntity(entity) + } Some(entity) => CmdResult::CommitAndExit(entity), None => CmdResult::NeedPoint, } @@ -416,9 +908,7 @@ impl InsertBlockCommand { fn finish_insert(&mut self) -> Option { let (attdefs, values) = match &self.step { - Step::FillAttr { - attdefs, values, .. - } => (attdefs.clone(), values.clone()), + Step::FillAttr { attdefs, values, .. } => (attdefs.clone(), values.clone()), _ => return None, }; let mut insert = self.pending_insert.take()?; @@ -431,10 +921,12 @@ impl InsertBlockCommand { attribute.apply_transform(&transform); insert.attributes.push(attribute); } + if self.repeat { + self.step = Step::Point; + } Some(EntityType::Insert(insert)) } } - // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["INSERT"] }); // InsertBlockCommand +inventory::submit!(crate::command::CommandRegistration { names: &["INSERT", "-INSERT"] }); diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index b79112659..573ce53d2 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -105,7 +105,7 @@ impl CadModule for InsertModule { title: "Block", tools: vec![ RibbonItem::LargeTool(mview_block::tool()), - RibbonItem::LargeTool(insert_block::tool()), + insert_block::gallery(), RibbonItem::Tool(create_block::tool()), RibbonItem::Tool(edit_block::tool()), RibbonItem::Tool(base_point::tool()), diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 067b4cbf4..6108ac028 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1234,6 +1234,46 @@ impl Scene { self.bump_geometry(); } + /// Replace the contents and base point of block `name`, defining it when + /// absent. References keep pointing at it and show the new contents. + pub fn redefine_block_raw( + &mut self, + name: &str, + base_point: codec::types::Vector3, + entities: Vec, + ) { + let Some((br_handle, block_handle)) = self + .document + .block_records + .get(name) + .map(|br| (br.handle, br.block_entity_handle)) + else { + return self.define_block_raw(name, base_point, entities); + }; + let owned: Vec = self + .document + .entities() + .filter(|e| { + e.common().owner_handle == br_handle + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .map(|e| e.common().handle) + .collect(); + for h in owned { + self.document.remove_entity(h); + } + if let Some(EntityType::Block(block)) = self.document.get_entity_mut(block_handle) { + block.base_point = base_point; + } + for mut entity in entities { + Self::reset_clone_subhandles(&mut self.document, &mut entity); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = br_handle; + let _ = self.document.add_entity(entity); + } + self.bump_geometry(); + } + pub(super) fn synced_hatch_models( &self, target_block: Handle, diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index dae84c28f..a21ee014b 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -20,7 +20,7 @@ use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem}; use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; -mod widgets; +pub(crate) mod widgets; mod draw_panel; pub(crate) use draw_panel::tools as panel_tools; mod modify_panel; @@ -745,6 +745,19 @@ impl Ribbon { // ── Dropdown overlay ────────────────────────────────────────────────── + /// Place `panel` (`w` wide) under dropdown `id`, closing on a click + /// outside it. + pub fn place_dropdown<'a>( + &self, + id: &str, + panel: Element<'a, Message>, + w: f32, + win_w: f32, + ) -> Element<'a, Message> { + let (align_right, h_pad, top) = self.dd_anchor(id, w, win_w); + dropdown_backdrop(position_ribbon_dropdown(panel, align_right, h_pad, top)) + } + pub fn dropdown_overlay( &self, undo_labels: &[String], diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index c5ea6acfa..74e7d9393 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -446,7 +446,7 @@ pub(super) fn combo_btn_style( } } -pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style { +pub(crate) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style { let palette = theme.palette(); let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) { palette.background.weak @@ -460,7 +460,7 @@ pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button:: } } -pub(super) fn popup_panel_style(theme: &Theme) -> container::Style { +pub(crate) fn popup_panel_style(theme: &Theme) -> container::Style { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.base.color)), diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index c5129e45b..d6f85d30c 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -1,92 +1,293 @@ -//! Docked "Insert Block" panel: a searchable grid of block thumbnails that -//! inserts a clicked block, mirrors the existing `InsertBlockCommand` flow. +//! The docked Blocks palette: Current Drawing, Recent, Favorites and +//! Libraries tabs over one block list, a wildcard filter, six views, and the +//! insertion options. A click on a block starts placing it with the options; +//! a block dragged onto the drawing goes in where it is dropped. +use std::path::PathBuf; + +use crate::app::settings::{BlockInsertOptions, PaletteBlockRef}; use crate::app::Message; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; use crate::ui::dock::PanelId; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, field_style}; +use crate::ui::window::pdf_dialogs::{accent_text, card_style, well_style}; use iced::widget::canvas::{Frame, Path, Program, Stroke}; -use iced::widget::{button, canvas, column, container, row, scrollable, text, text_input}; +use iced::widget::{ + button, canvas, checkbox, column, container, image, mouse_area, pick_list, row, scrollable, + text, text_input, Space, +}; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; const TOOL_H: f32 = 22.0; -/// Character budget for a block card's label so all rows stay one line. -/// Fits the narrowest card (Small = 3 per row) at size 11 without wrapping. -const MAX_LABEL_CHARS: usize = 12; -/// Fixed single-line height of a card's label. Clips any text that still -/// won't fit so every cell in a grid row shares the same height. +/// Fixed single-line height of a card's label so every cell in a row +/// shares the same height. const LABEL_LINE_H: f32 = 16.0; +/// The palette's tabs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Current, + Recent, + Favorites, + Libraries, +} + +impl Tab { + fn label(self) -> String { + match self { + Tab::Current => crate::t!("Current Drawing"), + Tab::Recent => crate::t!("Recent"), + Tab::Favorites => crate::t!("Favorites"), + Tab::Libraries => crate::t!("Libraries"), + } + .into_owned() + } + + fn heading(self) -> String { + match self { + Tab::Current => crate::t!("Current Drawing Blocks"), + Tab::Recent => crate::t!("Recent Blocks"), + Tab::Favorites => crate::t!("Favorite Blocks"), + Tab::Libraries => crate::t!("Library Blocks"), + } + .into_owned() + } +} + +/// How the list is drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ViewMode { + ExtraLarge, + #[default] + Large, + Medium, + Small, + Details, + List, +} + +impl ViewMode { + pub const ALL: [ViewMode; 6] = [ + ViewMode::ExtraLarge, + ViewMode::Large, + ViewMode::Medium, + ViewMode::Small, + ViewMode::Details, + ViewMode::List, + ]; + + pub fn from_u8(v: u8) -> Self { + Self::ALL.get(v as usize).copied().unwrap_or_default() + } + + pub fn label(self) -> String { + match self { + ViewMode::ExtraLarge => crate::t!("Extra Large Icons"), + ViewMode::Large => crate::t!("Large Icons"), + ViewMode::Medium => crate::t!("Medium Icons"), + ViewMode::Small => crate::t!("Small Icons"), + ViewMode::Details => crate::t!("Details"), + ViewMode::List => crate::t!("List"), + } + .into_owned() + } + + /// Cards per row and thumbnail height for the icon views. + fn grid(self) -> Option<(usize, f32)> { + match self { + ViewMode::ExtraLarge => Some((2, 120.0)), + ViewMode::Large => Some((3, 70.0)), + ViewMode::Medium => Some((4, 46.0)), + _ => None, + } + } +} + +/// One thing the list shows. +#[derive(Debug, Clone, PartialEq)] +pub enum Item { + /// A block of the current drawing. + Current(String), + /// A recent or favorite block, possibly from another drawing. + Ref(PaletteBlockRef), + /// A drawing in a library folder (inserted as a block). + File(PathBuf), + /// A sub-folder of a library folder. + Folder(PathBuf), +} + +impl Item { + pub fn name(&self) -> String { + match self { + Item::Current(name) => name.clone(), + Item::Ref(r) => r.name.clone(), + Item::File(p) | Item::Folder(p) => p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + } + } +} + +/// A switch or value of the Options card. +#[derive(Debug, Clone)] +pub enum OptionMsg { + InsertionPoint(bool), + Scale(bool), + Uniform(bool), + X(String), + Y(String), + Z(String), + Rotation(bool), + Angle(String), + AutoPlacement(bool), + Repeat(bool), + Explode(bool), +} + +/// The block context menu's commands. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MenuAction { + Insert, + Redefine, + Favorite, + Unfavorite, + Edit, + Remove, +} + /// Every message the block palette emits. Wrapped in `Message::BlockPalette`. #[derive(Debug, Clone)] pub enum BlockPaletteMsg { Search(String), - CyclePreviewSize, + Tab(Tab), + View(ViewMode), PickFile, - Insert(String), + FilePicked(Result), Refresh, - FilePicked(Result), + Option(OptionMsg), + ToggleOptions, + LibraryBrowse, + LibraryPicked(Result), + LibrarySelect(String), + LibraryUp, + /// Pressed on an item (a click when released on it, else a drag). + Press(Item), + Release(Item), + Menu(Item, MenuAction), } -/// Thumbnail box sizes for the three preview-size settings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PreviewSize { - #[default] - Small, - Medium, - Large, -} - -impl PreviewSize { - /// Number of cards per grid row. - fn columns(self) -> usize { - match self { - PreviewSize::Small => 3, - PreviewSize::Medium => 2, - PreviewSize::Large => 1, - } - } - /// Height (px) of the thumbnail box inside each card. - fn box_height(self) -> f32 { - match self { - PreviewSize::Small => 56.0, - PreviewSize::Medium => 84.0, - PreviewSize::Large => 120.0, - } - } -} - -/// Advance to the next preview size (Small → Medium → Large → Small). -pub fn cycle_preview_size(s: PreviewSize) -> PreviewSize { - match s { - PreviewSize::Small => PreviewSize::Medium, - PreviewSize::Medium => PreviewSize::Large, - PreviewSize::Large => PreviewSize::Small, - } -} - -/// One block's cached preview wires. +/// One block of the current drawing with its cached preview. pub struct BlockEntry { pub name: String, pub wires: Vec, + pub annotative: bool, + pub dynamic: bool, +} + +/// A library folder entry. +pub struct LibraryEntry { + pub path: PathBuf, + pub folder: bool, + pub thumb: Option, } /// Panel state held on the app. #[derive(Default)] pub struct BlockPalette { + pub tab: Tab, pub search: String, - pub preview_size: PreviewSize, - /// Cached (name, wires) pairs, rebuilt on refresh. + pub view: ViewMode, + /// Current drawing blocks, rebuilt on refresh. pub blocks: Vec, - /// Block name currently being placed (INSERT active), for the highlight. + /// Block name currently being placed, for the highlight. pub placing: Option, - /// Last known block-name list, for the cheap stale check. pub cached_names: Vec, - /// Document tab and block-definition revision that produced `blocks`. - /// Names alone are not enough: separate drawings can share block names, - /// and a block definition can change without being renamed. pub source_tab_id: Option, pub source_block_epoch: u64, + pub recent: Vec, + pub favorites: Vec, + /// Library roots, last used first. + pub libraries: Vec, + /// The folder shown (a library root or one of its sub-folders). + pub library_dir: Option, + pub library_entries: Vec, + /// Thumbnails of drawings recent / favorite entries point at. + pub file_thumbs: std::collections::HashMap>, + pub options: BlockInsertOptions, + /// The typed X / Y / Z / angle fields. + pub option_text: [String; 4], + pub options_collapsed: bool, + /// The item a press started on (a drag until released on it). + pub pressed: Option, + /// Blocks brought in from drawings this session (upper name → file), + /// so the Recent list remembers the drawing. + pub file_sources: std::collections::HashMap, + /// Previews of blocks seen this session, keyed `source|NAME`, for + /// recent / favorite blocks of other drawings. + pub ref_wires: std::collections::HashMap>, +} + +impl BlockPalette { + pub fn set_options(&mut self, options: BlockInsertOptions) { + self.option_text = [ + short(options.x), + short(options.y), + short(options.z), + short(options.angle), + ]; + self.options = options; + } + + /// The items of the current tab that pass the filter. + pub fn items(&self) -> Vec { + let pattern = self.search.trim(); + let pass = |name: &str| { + pattern.is_empty() || crate::io::xref_model::wildcard_match(name, pattern) + }; + match self.tab { + Tab::Current => self + .blocks + .iter() + .filter(|b| pass(&b.name)) + .map(|b| Item::Current(b.name.clone())) + .collect(), + Tab::Recent => self + .recent + .iter() + .filter(|r| pass(&r.name)) + .map(|r| Item::Ref(r.clone())) + .collect(), + Tab::Favorites => self + .favorites + .iter() + .filter(|r| pass(&r.name)) + .map(|r| Item::Ref(r.clone())) + .collect(), + Tab::Libraries => self + .library_entries + .iter() + .filter(|e| e.folder || pass(&display_name(&e.path))) + .map(|e| if e.folder { Item::Folder(e.path.clone()) } else { Item::File(e.path.clone()) }) + .collect(), + } + } + + fn current_entry(&self, name: &str) -> Option<&BlockEntry> { + self.blocks.iter().find(|b| b.name.eq_ignore_ascii_case(name)) + } +} + +pub fn short(v: f64) -> String { + let t = format!("{v:.4}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn display_name(path: &std::path::Path) -> String { + path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() } /// Canvas program that draws a block's wires fit-to-box inside its bounds. @@ -106,7 +307,7 @@ impl<'a> Program for BlockPreviewCanvas<'a> { _cursor: iced::mouse::Cursor, ) -> Vec { let mut frame = Frame::new(renderer, bounds.size()); - let pad = 6.0; + let pad = 4.0; let inner_w = (bounds.width - 2.0 * pad).max(1.0); let inner_h = (bounds.height - 2.0 * pad).max(1.0); let (mut minx, mut miny, mut maxx, mut maxy) = @@ -124,7 +325,6 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } } if !any { - // Placeholder box so an empty block still reads as a card. let rect = Path::rectangle(iced::Point::new(pad, pad), iced::Size::new(inner_w, inner_h)); frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }); return vec![frame.into_geometry()]; @@ -134,9 +334,7 @@ impl<'a> Program for BlockPreviewCanvas<'a> { let scale = (inner_w / span_x).min(inner_h / span_y); let ox = (bounds.width - span_x * scale) * 0.5; let oy = (bounds.height - span_y * scale) * 0.5; - // World up → screen down; centre the fitted AABB. let map = |x: f32, y: f32| iced::Point::new(ox + (x - minx) * scale, oy + (maxy - y) * scale); - // Solid fills first so strokes sit on top. for w in self.wires { let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 0.55 }; for tri in w.fill_tris.chunks(3) { @@ -151,12 +349,11 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } } } - // Polylines, split into runs on NaN separators (NaN = polyline break). for w in self.wires { let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 1.0 }; let width = if w.line_weight_px > 1.5 { 2.0 } else { 1.0 }; let mut run: Vec = Vec::new(); - let flush = |frame: &mut Frame, run: &mut Vec, col: Color, width: f32| { + let flush = |frame: &mut Frame, run: &mut Vec| { if run.len() >= 2 { let path = Path::new(|p| { p.move_to(run[0]); @@ -172,112 +369,501 @@ impl<'a> Program for BlockPreviewCanvas<'a> { if p[0].is_finite() && p[1].is_finite() { run.push(map(p[0], p[1])); } else { - flush(&mut frame, &mut run, col, width); + flush(&mut frame, &mut run); } } - flush(&mut frame, &mut run, col, width); + flush(&mut frame, &mut run); } vec![frame.into_geometry()] } } +fn pm(m: BlockPaletteMsg) -> Message { + Message::BlockPalette(m) +} + +/// The thumbnail of an item, `size` pixels tall. +fn thumb<'a>(palette: &'a BlockPalette, item: &Item, size: f32) -> Element<'a, Message> { + let blank = || -> Element<'a, Message> { + container(Space::new()) + .width(Fill) + .height(Length::Fixed(size)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.10))), + ..Default::default() + }) + .into() + }; + let wires = |name: &str| palette.current_entry(name).map(|e| &e.wires[..]); + let file_thumb = |path: &str| palette.file_thumbs.get(path).cloned().flatten(); + match item { + Item::Current(name) => match wires(name) { + Some(w) => canvas(BlockPreviewCanvas { wires: w }).width(Fill).height(Length::Fixed(size)).into(), + None => blank(), + }, + Item::Ref(r) => { + if !r.drawing { + let seen = palette.ref_wires.get(&format!("{}|{}", r.source, r.name.to_ascii_uppercase())); + if let Some(w) = seen.map(|w| &w[..]).or_else(|| wires(&r.name)) { + return canvas(BlockPreviewCanvas { wires: w }).width(Fill).height(Length::Fixed(size)).into(); + } + } + match file_thumb(&r.source) { + Some(h) if r.drawing => image(h).width(Fill).height(Length::Fixed(size)).into(), + _ => blank(), + } + } + Item::File(path) => { + match palette.library_entries.iter().find(|e| &e.path == path).and_then(|e| e.thumb.clone()) { + Some(h) => image(h).width(Fill).height(Length::Fixed(size)).into(), + None => blank(), + } + } + Item::Folder(_) => container(crate::ui::icons::themed_secondary(crate::ui::icons::FOLDER_OPEN, size * 0.6)) + .center_x(Fill) + .center_y(Length::Fixed(size)) + .into(), + } +} + +/// The context menu for an item. +fn item_menu(tab: Tab, item: Item, in_drawing: bool, external: bool) -> Element<'static, Message> { + let entry = |label: String, action: MenuAction, enabled: bool| -> Element<'static, Message> { + let b = button(text(label).size(12)) + .width(Fill) + .padding([5, 12]) + .style(|theme: &Theme, status| { + let mut s = button::text(theme, status); + if matches!(status, button::Status::Hovered) { + s.background = Some(Background::Color(theme.palette().primary.base.color)); + s.text_color = theme.palette().primary.base.text; + } + s + }); + if enabled { + b.on_press(pm(BlockPaletteMsg::Menu(item.clone(), action))).into() + } else { + b.into() + } + }; + let favorite = if tab == Tab::Favorites { + entry(crate::t!("Remove from Favorites").into_owned(), MenuAction::Unfavorite, true) + } else { + entry(crate::t!("Add to Favorites").into_owned(), MenuAction::Favorite, !matches!(item, Item::Folder(_))) + }; + let remove_label = match tab { + Tab::Recent | Tab::Favorites => crate::t!("Remove from List"), + _ => crate::t!("Remove Block"), + }; + let remove_enabled = match tab { + Tab::Current => true, + Tab::Recent | Tab::Favorites => true, + Tab::Libraries => false, + }; + container( + column![ + entry(crate::t!("Insert").into_owned(), MenuAction::Insert, !matches!(item, Item::Folder(_))), + entry(crate::t!("Redefine").into_owned(), MenuAction::Redefine, external), + favorite, + container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }), + entry(crate::t!("Edit Block").into_owned(), MenuAction::Edit, in_drawing), + entry(remove_label.into_owned(), MenuAction::Remove, remove_enabled), + ] + .spacing(1), + ) + .padding(4) + .width(Length::Fixed(200.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} + +/// One item as a card / row, with press, release and the context menu. +fn item_view<'a>(palette: &'a BlockPalette, item: Item) -> Element<'a, Message> { + let name = item.name(); + let placing = matches!(&item, Item::Current(n) if palette.placing.as_deref() == Some(n.as_str())); + let in_drawing = palette.current_entry(&name).is_some() + && !matches!(&item, Item::Ref(r) if r.drawing) + && !matches!(item, Item::File(_) | Item::Folder(_)); + let external = matches!(&item, Item::Ref(r) if !r.source.is_empty()) || matches!(item, Item::File(_)); + let body: Element<'a, Message> = match palette.view.grid() { + Some((_, size)) => column![ + thumb(palette, &item, size), + text(crate::ui::text_util::elide(&name, 14)) + .size(11) + .width(Fill) + .height(Length::Fixed(LABEL_LINE_H)) + .center(), + ] + .spacing(4) + .into(), + None => match palette.view { + ViewMode::Small => row![ + container(thumb(palette, &item, 16.0)).width(Length::Fixed(20.0)), + text(crate::ui::text_util::elide(&name, 16)).size(11), + ] + .spacing(4) + .align_y(iced::Center) + .into(), + ViewMode::Details => { + let (ann, dynamic) = palette + .current_entry(&name) + .map_or((false, false), |e| (e.annotative, e.dynamic)); + let yes_no = |v: bool| if v { crate::t!("Yes") } else { crate::t!("No") }.into_owned(); + row![ + container(thumb(palette, &item, 16.0)).width(Length::Fixed(40.0)), + text(name.clone()).size(11).width(Fill), + text(format!("{} / {}", yes_no(ann), yes_no(dynamic))) + .size(11) + .width(Length::Fixed(140.0)), + ] + .spacing(4) + .align_y(iced::Center) + .into() + } + _ => text(name.clone()).size(11).width(Fill).into(), + }, + }; + let card = container(body) + .padding(if palette.view.grid().is_some() { 6 } else { 3 }) + .width(Fill) + .style(move |theme: &Theme| { + let (bg, fg) = block_card_colors(theme, placing, button::Status::Active); + container::Style { + background: Some(Background::Color(bg)), + text_color: Some(fg), + border: Border { + color: block_card_border(theme, placing), + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } + }); + let area = mouse_area(card) + .on_press(pm(BlockPaletteMsg::Press(item.clone()))) + .on_release(pm(BlockPaletteMsg::Release(item.clone()))) + .interaction(iced::mouse::Interaction::Pointer); + let tab = palette.tab; + iced_aw::ContextMenu::new(area, move || item_menu(tab, item.clone(), in_drawing, external)).into() +} + +fn icon_button<'a>(icon: IconKind, tip: &str, msg: BlockPaletteMsg) -> Element<'a, Message> { + let icon_el: Element<'_, Message> = match icon { + IconKind::Glyph(s) => text(s).size(15).into(), + IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), + }; + crate::ui::dock::tool_button(icon_el, crate::t!(tip).into_owned(), pm(msg)) +} + +fn options_card<'a>(palette: &'a BlockPalette) -> Element<'a, Message> { + let o = &palette.options; + let check = |label: String, on: bool, f: fn(bool) -> OptionMsg| -> Element<'a, Message> { + checkbox(on) + .label(label) + .text_size(12) + .size(14) + .on_toggle(move |v| pm(BlockPaletteMsg::Option(f(v)))) + .into() + }; + let num = |value: &'a str, f: fn(String) -> OptionMsg, enabled: bool| -> Element<'a, Message> { + let mut input = text_input("", value).size(11).padding([3, 6]).width(Length::Fixed(48.0)).style(field_style); + if enabled { + input = input.on_input(move |v| pm(BlockPaletteMsg::Option(f(v)))); + } + input.into() + }; + let header = mouse_area( + row![ + text(crate::t!("Options").to_uppercase()).size(10).style(accent_text), + Space::new().width(Fill), + text(if palette.options_collapsed { "▸" } else { "▾" }).size(11), + ] + .align_y(iced::Center), + ) + .on_press(pm(BlockPaletteMsg::ToggleOptions)) + .interaction(iced::mouse::Interaction::Pointer); + let mut content = column![header].spacing(6); + if !palette.options_collapsed { + let scale_kinds = vec![crate::t!("Scale").into_owned(), crate::t!("Uniform Scale").into_owned()]; + let current_kind = scale_kinds[o.uniform as usize].clone(); + let uniform_label = scale_kinds[1].clone(); + let kind = pick_list(Some(current_kind), scale_kinds, |v: &String| v.clone()) + .on_select(move |v: String| pm(BlockPaletteMsg::Option(OptionMsg::Uniform(v == uniform_label)))) + .text_size(11) + .padding([3, 6]); + let fields_on = !o.scale; + let mut scale_row = row![ + checkbox(o.scale).size(14).on_toggle(|v| pm(BlockPaletteMsg::Option(OptionMsg::Scale(v)))), + kind, + ] + .spacing(6) + .align_y(iced::Center); + // The scale values sit on their own line under the switch. + let mut xyz_row = row![Space::new().width(Length::Fixed(20.0))].spacing(6).align_y(iced::Center); + if o.scale { + scale_row = scale_row.push(text(crate::t!("specified on screen")).size(11).style(muted_style)); + } else if o.uniform { + xyz_row = xyz_row.push(num(&palette.option_text[0], OptionMsg::X, fields_on)); + } else { + xyz_row = xyz_row + .push(text("X").size(11)) + .push(num(&palette.option_text[0], OptionMsg::X, fields_on)) + .push(text("Y").size(11)) + .push(num(&palette.option_text[1], OptionMsg::Y, fields_on)) + .push(text("Z").size(11)) + .push(num(&palette.option_text[2], OptionMsg::Z, fields_on)); + } + let mut rot_row = row![check(crate::t!("Rotation").into_owned(), o.rotation, OptionMsg::Rotation), Space::new().width(Fill)] + .spacing(6) + .align_y(iced::Center); + if o.rotation { + rot_row = rot_row.push(text(crate::t!("specified on screen")).size(11).style(muted_style)); + } else { + rot_row = rot_row + .push(num(&palette.option_text[3], OptionMsg::Angle, true)) + .push(text(crate::t!("Angle")).size(11)); + } + content = content + .push(check(crate::t!("Insertion Point").into_owned(), o.insertion_point, OptionMsg::InsertionPoint)) + .push(scale_row); + if !o.scale { + content = content.push(xyz_row); + } + content = content + .push(rot_row) + .push(check(crate::t!("Auto-Placement").into_owned(), o.auto_placement, OptionMsg::AutoPlacement)) + .push(check(crate::t!("Repeat Placement").into_owned(), o.repeat, OptionMsg::Repeat)) + .push(check(crate::t!("Explode").into_owned(), o.explode, OptionMsg::Explode)); + } + container(content).padding([8, 10]).width(Fill).style(card_style).into() +} + /// Build the docked panel element from the palette state. pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<'_, Message> { - let query = palette.search.trim().to_lowercase(); - let filtered: Vec<&BlockEntry> = palette - .blocks - .iter() - .filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query)) - .collect(); + let title_bar = crate::ui::dock::title_bar(PanelId::BlockPalette, crate::t!("Blocks").into_owned(), auto_collapse); - let title_bar = crate::ui::dock::title_bar( - PanelId::BlockPalette, - crate::t!("Block Palette").into_owned(), - auto_collapse, - ); + let tabs = { + let buttons = [Tab::Current, Tab::Recent, Tab::Favorites, Tab::Libraries].map(|t| { + button(text(t.label()).size(10).width(Fill).align_x(iced::Center)) + .on_press(pm(BlockPaletteMsg::Tab(t))) + .style(button_style(t == palette.tab)) + .padding([5, 1]) + .width(Fill) + .into() + }); + container(iced::widget::Row::with_children(buttons).spacing(2)) + .padding(2) + .width(Fill) + .style(well_style) + }; - let search_input = text_input(&crate::t!("Search blocks…"), &palette.search) - .on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v))) + let search_input = text_input(&crate::t!("Filter... (* ? wildcards)"), &palette.search) + .on_input(|v| pm(BlockPaletteMsg::Search(v))) .padding([4, 8]) - .size(12); - + .size(12) + .style(field_style); + let view_pick = pick_list(Some(palette.view.label()), ViewMode::ALL.map(|v| v.label()).to_vec(), |v: &String| v.clone()) + .on_select(|label: String| { + pm(BlockPaletteMsg::View( + ViewMode::ALL.into_iter().find(|v| v.label() == label).unwrap_or_default(), + )) + }) + .text_size(11) + .width(Length::Fixed(118.0)) + .padding([3, 6]); let header = row![ - search_input, + search_input.width(Fill), icon_button( IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")), - "Insert from file", + "Insert a drawing as a block", BlockPaletteMsg::PickFile, ), - icon_button( - IconKind::Svg(include_bytes!("../../../assets/icons/blocks/preview_size.svg")), - "Preview size", - BlockPaletteMsg::CyclePreviewSize, - ), + view_pick, ] .spacing(4) .align_y(iced::Center); - let body: Element<'_, Message> = if filtered.is_empty() { - let msg = if palette.cached_names.is_empty() { - "No blocks in this drawing" - } else { - "No matches" + let mut top = column![tabs, header].spacing(6); + if palette.tab == Tab::Libraries { + let names: Vec = palette.libraries.clone(); + let selected = palette.libraries.first().cloned(); + let mut lib_row = row![ + pick_list(selected, names, |v: &String| v.clone()) + .on_select(|v: String| pm(BlockPaletteMsg::LibrarySelect(v))) + .placeholder(crate::t!("No library").into_owned()) + .text_size(11) + .padding([3, 6]) + .width(Fill), + button(text("…").size(12)).on_press(pm(BlockPaletteMsg::LibraryBrowse)).style(button_style(false)).padding([3, 8]), + ] + .spacing(4) + .align_y(iced::Center); + let in_sub = palette.library_dir.as_ref().is_some_and(|d| { + palette.libraries.first().is_some_and(|root| d != &PathBuf::from(root)) + }); + if in_sub { + lib_row = lib_row.push(button(text("↑").size(12)).on_press(pm(BlockPaletteMsg::LibraryUp)).style(button_style(false)).padding([3, 8])); + } + top = top.push(lib_row); + if let (Some(root), Some(dir)) = (palette.libraries.first(), palette.library_dir.as_ref()) { + let root = PathBuf::from(root); + let shown = match dir.strip_prefix(root.parent().unwrap_or(&root)) { + Ok(rel) => rel.display().to_string(), + Err(_) => dir.display().to_string(), + }; + top = top.push(text(format!("{} {shown}", crate::t!("Path:"))).size(10).style(accent_text)); + } + } + top = top.push(text(palette.tab.heading().to_uppercase()).size(10).style(accent_text)); + + let items = palette.items(); + let body: Element<'_, Message> = if items.is_empty() { + let msg = match palette.tab { + Tab::Current if palette.cached_names.is_empty() => crate::t!("No blocks in this drawing"), + Tab::Libraries if palette.libraries.is_empty() => crate::t!("Choose a library folder with …"), + _ => crate::t!("No matches"), }; - container( - text(crate::t!(msg)) - .size(12) - .style(|theme: &Theme| iced::widget::text::Style { - color: Some( - theme - .palette() - .background - .base - .text - .scale_alpha(0.72), - ), - }), - ) - .center_x(Fill) - .center_y(Fill) + container(text(msg).size(12).style(muted_style)).center_x(Fill).center_y(Fill).width(Fill).height(Fill).into() + } else { + let mut col = column![].spacing(4); + match palette.view.grid() { + Some((per_row, _)) => { + for chunk in items.chunks(per_row) { + let mut r = row![].spacing(6).width(Fill); + for item in chunk { + r = r.push(item_view(palette, item.clone())); + } + for _ in chunk.len()..per_row { + r = r.push(Space::new().width(Fill)); + } + col = col.push(r); + } + } + None if palette.view == ViewMode::Small => { + for chunk in items.chunks(2) { + let mut r = row![].spacing(6).width(Fill); + for item in chunk { + r = r.push(item_view(palette, item.clone())); + } + if chunk.len() < 2 { + r = r.push(Space::new().width(Fill)); + } + col = col.push(r); + } + } + None => { + if palette.view == ViewMode::Details { + col = col.push( + row![ + text(crate::t!("Icon")).size(11).style(muted_style).width(Length::Fixed(40.0)), + text(format!("{} ▲", crate::t!("Name"))).size(11).style(muted_style).width(Fill), + text(crate::t!("Annotative / Dynamic")).size(11).style(muted_style).width(Length::Fixed(140.0)), + ] + .spacing(4), + ); + } + for item in items { + col = col.push(item_view(palette, item)); + } + } + } + scrollable(container(col).padding(iced::Padding { top: 0.0, right: 8.0, bottom: 6.0, left: 0.0 })) .width(Fill) .height(Fill) .into() - } else { - let mut col = column![].spacing(6); - for chunk in filtered.chunks(palette.preview_size.columns()) { - let mut r = row![].spacing(6).width(Fill); - for block in chunk { - r = r.push(block_card(palette, block)); - } - col = col.push(r); - } - scrollable( - container(col).padding(iced::Padding { - top: 0.0, - right: 8.0, - bottom: 6.0, - left: 0.0, - }), - ) - .width(Fill) - .height(Fill) - .into() }; - crate::ui::dock::frame(column![title_bar, header, body].spacing(6), width) + crate::ui::dock::frame(column![title_bar, top, body, options_card(palette)].spacing(6), width) } -/// Theme-aware foreground/background pair for a block card. -/// -/// Centralizes the card's text/background mapping so the contrast suite can -/// exercise the exact colors the UI renders (instead of re-implementing the -/// match). Normal cards use the `base` pair, hovered/pressed cards use the -/// `strong` pair, and the actively-placing card uses the `primary.base` pair. -pub(crate) fn block_card_colors( - theme: &Theme, - is_placing: bool, - status: button::Status, -) -> (Color, Color) { +/// Width of the ribbon's block gallery. +pub const GALLERY_W: f32 = 300.0; + +/// The ribbon Insert gallery: the drawing's blocks (a click inserts one), +/// then the ways into the palette's other tabs. +pub fn gallery(palette: &BlockPalette) -> Element<'_, Message> { + let ribbon_command = |cmd: String| Message::RibbonToolClick { + tool_id: "INSERT".to_string(), + event: crate::modules::ModuleEvent::Command(cmd), + }; + let mut grid = column![].spacing(4); + for chunk in palette.blocks.chunks(4) { + let mut r = row![].spacing(4).width(Fill); + for block in chunk { + let card = column![ + canvas(BlockPreviewCanvas { wires: &block.wires }).width(Fill).height(Length::Fixed(48.0)), + text(crate::ui::text_util::elide(&block.name, 10)) + .size(10) + .width(Fill) + .height(Length::Fixed(LABEL_LINE_H)) + .center(), + ] + .spacing(2); + r = r.push( + button(card) + .on_press(ribbon_command(format!("_BLOCKINSERT {}", block.name))) + .style(crate::ui::ribbon::widgets::popup_row_style) + .padding(3) + .width(Fill), + ); + } + for _ in chunk.len()..4 { + r = r.push(Space::new().width(Fill)); + } + grid = grid.push(r); + } + let blocks: Element<'_, Message> = if palette.blocks.is_empty() { + container(text(crate::t!("No blocks in this drawing")).size(11).style(muted_style)) + .padding(10) + .center_x(Fill) + .into() + } else { + scrollable(container(grid).padding(iced::Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 0.0 })) + .height(Length::Shrink) + .into() + }; + let link = |label: std::borrow::Cow<'static, str>, tab: &str| -> Element<'_, Message> { + button(text(label.into_owned()).size(11)) + .on_press(ribbon_command(format!("_BLOCKSPALETTE {tab}"))) + .style(crate::ui::ribbon::widgets::popup_row_style) + .width(Fill) + .padding([5, 10]) + .into() + }; + container( + column![ + text(crate::t!("Current Drawing Blocks").to_uppercase()).size(10).style(accent_text), + container(blocks).height(Length::Fixed(((palette.blocks.len().div_ceil(4)) as f32 * 79.0).clamp(36.0, 260.0))), + container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }), + link(crate::t!("Recent Blocks..."), "RECENT"), + link(crate::t!("Favorite Blocks..."), "FAVORITES"), + link(crate::t!("Blocks from Libraries..."), "LIBRARIES"), + ] + .spacing(6), + ) + .padding(8) + .width(Length::Fixed(GALLERY_W)) + .style(crate::ui::ribbon::widgets::popup_panel_style) + .into() +} + +/// Theme-aware foreground/background pair for a block card (normal, +/// hovered/pressed, or the block being placed). +pub(crate) fn block_card_colors(theme: &Theme, is_placing: bool, status: button::Status) -> (Color, Color) { let palette = theme.palette(); if is_placing { (palette.primary.base.color, palette.primary.base.text) @@ -286,15 +872,12 @@ pub(crate) fn block_card_colors( button::Status::Hovered | button::Status::Pressed => { (palette.background.strong.color, palette.background.strong.text) } - _ => ( - palette.background.base.color, - palette.background.base.text, - ), + _ => (palette.background.weak.color, palette.background.weak.text), } } } -/// Border color for a block card in a given state (exposed for contrast tests). +/// Border color for a block card in a given state. pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color { let palette = theme.palette(); if is_placing { @@ -304,11 +887,8 @@ pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color { } } -/// Theme-aware foreground for the palette's header icon buttons. -/// -/// Transparent resting state composites over the dock (`base`) background; -/// hovered/pressed state sits on the `strong` surface, so each state pairs -/// with its own background's text color to preserve contrast. +/// Theme-aware foreground for the palette's header icon buttons (shared with +/// the dock's tool buttons). pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status) -> Color { let palette = theme.palette(); match status { @@ -316,64 +896,3 @@ pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status _ => palette.background.base.text, } } - -fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> { - let is_placing = palette.placing.as_deref() == Some(block.name.as_str()); - let preview = canvas(BlockPreviewCanvas { wires: &block.wires }) - .width(Fill) - .height(Length::Fixed(palette.preview_size.box_height())); - // Fixed height guarantees the label occupies exactly one line, so every - // cell in a row keeps the same height even for long names (the elide budget - // handles overflow, and this clips anything that still won't fit). - // - // NOTE: no explicit `.color(...)` here — the label inherits the button's - // `text_color` so normal/hover/placing states each get a theme-aware - // foreground with sufficient contrast on both light and dark themes. - // (A hardcoded `Color::WHITE` is unreadable on light themes.) - let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS)) - .size(11) - .width(Fill) - .height(Length::Fixed(LABEL_LINE_H)) - .center(); - let content = column![preview, label].spacing(4).padding(6); - button(content) - .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) - .width(Fill) - .style(move |theme: &Theme, status| { - let (bg, fg) = block_card_colors(theme, is_placing, status); - let border = block_card_border(theme, is_placing); - button::Style { - background: Some(Background::Color(bg)), - border: Border { - color: border, - width: 1.0, - radius: 3.0.into(), - }, - text_color: fg, - ..Default::default() - } - }) - .into() -} - -fn icon_button<'a>(icon: IconKind, tip: &str, msg: BlockPaletteMsg) -> Element<'a, Message> { - let icon_el: Element<'_, Message> = match icon { - // No explicit color — inherits the button's theme-aware `text_color` - // so glyphs stay legible on light themes. - IconKind::Glyph(s) => text(s).size(15).into(), - IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), - }; - crate::ui::dock::tool_button(icon_el, crate::t!(tip).into_owned(), Message::BlockPalette(msg)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn preview_size_cycles() { - assert_eq!(cycle_preview_size(PreviewSize::Small), PreviewSize::Medium); - assert_eq!(cycle_preview_size(PreviewSize::Medium), PreviewSize::Large); - assert_eq!(cycle_preview_size(PreviewSize::Large), PreviewSize::Small); - } -} diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index a59d918b1..e2cc8e4f9 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -233,13 +233,13 @@ pub fn click_page(selected: &mut Vec, anchor: &mut usize, page: usize, ct // accent-coloured card titles, segmented choices and toggle chips, with the // help button on the left of the footer and the named action on the right. -fn accent_text(theme: &Theme) -> text::Style { +pub(crate) fn accent_text(theme: &Theme) -> text::Style { text::Style { color: Some(theme.palette().primary.base.color), } } -fn card_style(theme: &Theme) -> container::Style { +pub(crate) fn card_style(theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), border: Border { @@ -251,7 +251,7 @@ fn card_style(theme: &Theme) -> container::Style { } } -fn well_style(theme: &Theme) -> container::Style { +pub(crate) fn well_style(theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(theme.palette().background.base.color)), border: Border { From 4ddef1f9ae97e7fa8bdf166cddc9f3027115db64 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:30:28 +0300 Subject: [PATCH 04/13] Blocks palette: larger Options collapse arrow Co-Authored-By: Claude Opus 5.5 --- src/ui/window/block_palette.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index d6f85d30c..516062984 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -587,7 +587,7 @@ fn options_card<'a>(palette: &'a BlockPalette) -> Element<'a, Message> { row![ text(crate::t!("Options").to_uppercase()).size(10).style(accent_text), Space::new().width(Fill), - text(if palette.options_collapsed { "▸" } else { "▾" }).size(11), + text(if palette.options_collapsed { "▸" } else { "▾" }).size(22), ] .align_y(iced::Center), ) From d596b5a6be435d154381e15fdeb05a34ad56d1ba Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:03:56 +0300 Subject: [PATCH 05/13] fix(view): zoom cursor per navigation tool, and ZOOM EXTENTS frames everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two viewport-navigation defects, both measured against commercial solutions. The cursor stayed a hand during a zoom. `selection_overlay` took one boolean for all three interactive navigation tools, so ZOOM Dynamic and ORBIT both showed PAN's grab hand — which reads as "drag the sheet", the wrong promise for either. Split it into a `NavCursor` enum: PAN keeps the open/closed hand, ORBIT gets four-way arrows, and ZOOM Dynamic gets the magnifier carrying the sign of the zoom actually under way — "+" dragging up, "-" dragging down, latched with a 2 px dead zone so a hand held still cannot strobe the glyph. ZOOM Dynamic also reused `box_anchor` to remember where a drag began, so a selection marquee rubber-banded across the screen while the view zoomed; the drawing is now suppressed, the state left alone for `reproject_box_anchor`. ZOOM EXTENTS did not reach the whole drawing, for three separate reasons, now answered by one shared `Scene::visible_block_bounds`: - A quartile filter dropped any wire whose centroid sat outside the inter-quartile consensus of the others. It was meant to stop one junk wire from a bad parse poisoning the box, but it could not tell parser junk from an outlying detail, a survey point, or a block placed away from the rest, and silently refused to frame them. Gone; the absolute 1e16 sentinel gate the paper path already used is the only filter, and the XLine / Ray display segments that motivated the heuristic are handled explicitly. - HATCH tessellates to no wires at all, and the model path never read the fill models, so a drawing whose outermost geometry is a fill — or which is nothing but fills — had nothing to frame. It now folds them in, reusing the renderer's own warm cache on the hot path rather than re-exploding every INSERT per ZOOM EXTENTS. - Inside a floating viewport the fit came from `compute_model_space_extents`, which walked only `key_vertices` — empty for every tessellated curve. Circles, arcs, ellipses and splines contributed nothing. Fixing it also corrects plot Extents, sheet-set framing, viewport auto-fit and SpaceMouse model bounds, which all inherited that blind spot. Bounds stay f64 into the camera: a UTM drawing lost whole metres to the old f32 round-trip. Drawing extents also no longer read the camera — the result is cached per geometry epoch, and a floating viewport's camera is derived FROM the extents, so `camera_for_viewport` recursed into a stack overflow. `model_space_extents_mixed_entities_accuracy` asserted the curve-blind result; it now asserts the true bounds. Co-Authored-By: Claude Opus 5 --- src/app/update/viewport.rs | 106 +++++++- src/app/view/mod.rs | 19 +- src/scene/camera_ops.rs | 393 ++++++++++++++---------------- src/scene/mod.rs | 138 +++-------- src/scene/pick/selection_state.rs | 5 + src/ui/overlay.rs | 68 ++++-- tests/integration.rs | 3 + tests/zoom_extents_coverage.rs | 123 ++++++++++ 8 files changed, 514 insertions(+), 341 deletions(-) create mode 100644 tests/zoom_extents_coverage.rs diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c12f3646d..537be7d3c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -48,6 +48,11 @@ enum ScrollIntent { Pan { dx: f32, dy: f32 }, } +/// How far a ZOOM Dynamic drag must travel vertically before the magnifier +/// cursor flips between "+" and "-". Without the dead zone a hand holding +/// still at the top of a drag strobes the glyph on every jittered pixel. +const ZOOM_DIR_FLIP_PX: f32 = 2.0; + /// A wheel notch zooms; a trackpad's two fingers pan. /// /// A wheel reports notches, a precise-scrolling device reports pixels, and that @@ -1238,7 +1243,14 @@ impl OpenCADStudio { self.tabs[i] .scene .record_nav_perf(crate::scene::NavPerfOp::Zoom, move_started); - self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + // Latch the direction for the magnifier cursor. A dead + // zone keeps the +/- glyph from strobing when a slow drag + // wobbles a pixel either side of where it started. + if dy.abs() >= ZOOM_DIR_FLIP_PX { + sel.zoom_dir_out = dy > 0.0; + } + sel.middle_last_pos = Some(p); return Task::none(); } // Shift+MMB drag orbits the model view instead of panning @@ -3471,6 +3483,9 @@ impl OpenCADStudio { sel.box_current = Some(p); sel.box_crossing = false; sel.box_crossing_locked = true; + // A fresh drag starts neutral: the magnifier shows "+" until + // the pointer has actually moved far enough to mean zoom out. + sel.zoom_dir_out = false; } return Task::none(); } @@ -3740,6 +3755,7 @@ impl OpenCADStudio { sel.box_anchor_world = None; sel.box_current = None; sel.box_crossing_locked = false; + sel.zoom_dir_out = false; drop(sel); self.arm_hover_after_navigation(i); return Task::none(); @@ -6727,6 +6743,94 @@ mod scroll_intent_tests { } } +#[cfg(test)] +mod zoom_dynamic_cursor_tests { + use super::*; + use crate::app::OpenCADStudio; + use crate::ui::overlay::NavCursor; + + fn zoom_armed_app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let _ = app.dispatch_command("ZOOM DYNAMIC"); + app + } + + fn drag_to(app: &mut OpenCADStudio, y: f32) { + let _ = app.update(Message::ViewportMove(Point::new(400.0, y))); + } + + fn zoom_dir_out(app: &OpenCADStudio) -> bool { + app.tabs[0].scene.selection.borrow().zoom_dir_out + } + + /// The magnifier has to carry the sign of the zoom actually under way: + /// dragging up zooms in, dragging back down zooms out. + #[test] + fn the_magnifier_follows_the_drag_direction() { + let mut app = zoom_armed_app(); + assert!(app.tabs[0].zoom_dynamic_mode); + drag_to(&mut app, 300.0); + let _ = app.update(Message::ViewportLeftPress); + // A fresh drag starts neutral — "+" until told otherwise. + assert!(!zoom_dir_out(&app)); + + drag_to(&mut app, 200.0); + assert!(!zoom_dir_out(&app), "dragging up zooms in"); + + drag_to(&mut app, 400.0); + assert!(zoom_dir_out(&app), "dragging down zooms out"); + + drag_to(&mut app, 250.0); + assert!(!zoom_dir_out(&app), "dragging up again zooms in"); + + // Releasing disarms the direction, so the next hover shows "+". + let _ = app.update(Message::ViewportLeftRelease); + assert!(!zoom_dir_out(&app)); + } + + /// Sub-pixel wobble while a hand holds still must not strobe the glyph. + #[test] + fn jitter_below_the_dead_zone_does_not_flip_the_glyph() { + let mut app = zoom_armed_app(); + drag_to(&mut app, 300.0); + let _ = app.update(Message::ViewportLeftPress); + drag_to(&mut app, 400.0); + assert!(zoom_dir_out(&app)); + + // Back up by less than ZOOM_DIR_FLIP_PX: still zooming out. + drag_to(&mut app, 399.0); + assert!(zoom_dir_out(&app), "1 px of wobble must not flip the sign"); + } + + /// Each navigation tool owns its own cursor. They used to share the pan + /// hand, which read as "drag the sheet" during a zoom and an orbit. + #[test] + fn each_navigation_tool_reports_its_own_mode() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + for (command, want) in [ + ("PAN", NavCursor::Pan), + ("ORBIT", NavCursor::Orbit), + ("ZOOM DYNAMIC", NavCursor::Zoom), + ] { + let _ = app.dispatch_command(command); + assert_eq!( + crate::app::view::nav_cursor(&app.tabs[0]), + want, + "{command} must have its own cursor" + ); + let _ = app.update(Message::CommandEscape); + assert_eq!( + crate::app::view::nav_cursor(&app.tabs[0]), + NavCursor::None, + "Esc must disarm {command}" + ); + } + } +} + #[cfg(test)] mod snap_elevation_tests { use super::snap_keeps_elevation; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6399320d8..e7ab2370e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -45,6 +45,23 @@ fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] { tab.scene.paper_bg_color } +/// Which navigation tool the viewport cursor should advertise. The three flags +/// are mutually exclusive — every command entry point clears all of them before +/// arming one (`crate::app::commands`) — so the order here only settles a state +/// that cannot occur. +pub(in crate::app) fn nav_cursor(tab: &DocumentTab) -> crate::ui::overlay::NavCursor { + use crate::ui::overlay::NavCursor; + if tab.pan_mode { + NavCursor::Pan + } else if tab.orbit_mode { + NavCursor::Orbit + } else if tab.zoom_dynamic_mode { + NavCursor::Zoom + } else { + NavCursor::None + } +} + /// Clear gap (px) kept between the render-mode bar (top-left) and the ViewCube /// (top-right) before the cube is judged to collide and hides. const VIEWCUBE_GAP: f32 = 12.0; @@ -894,7 +911,7 @@ bg={bg_ms:.1}ms n={view_count}" dividers, pane_move_rect, pane_drop_rect, - tab.pan_mode || tab.orbit_mode || tab.zoom_dynamic_mode, + nav_cursor(tab), self.ribbon.open_dropdown.is_some(), hover_locked, crosshair_background(tab, is_paper), diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index b9eed710d..a1f0ffc2e 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -81,6 +81,26 @@ fn rendered_wire_bounds( } } +/// Merge `pair` into `bounds`, dropping coordinates that are parser junk +/// rather than geometry: DXF writes ±1e20 as its "no extents" sentinel, and +/// past 1e16 an f32 has no resolution left to place a point with. +fn fold_sane_bounds( + bounds: &mut Option<(glam::DVec3, glam::DVec3)>, + pair: Option<(glam::DVec3, glam::DVec3)>, +) { + const SANE_EXTENT: f64 = 1.0e16; + let Some((lo, hi)) = pair else { + return; + }; + if lo.abs().max_element() >= SANE_EXTENT || hi.abs().max_element() >= SANE_EXTENT { + return; + } + *bounds = Some(match *bounds { + Some((min, max)) => (min.min(lo), max.max(hi)), + None => (lo, hi), + }); +} + fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option { rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5) } @@ -1321,6 +1341,155 @@ impl Scene { self.camera_generation += 1; } + /// Absolute-WCS bounds of everything visible in `block` — the one extents + /// walk behind ZOOM EXTENTS and the drawing-extents readers. + /// + /// Nothing is rejected here on a guess. An earlier version dropped any wire + /// whose centroid fell outside the inter-quartile consensus of the others, + /// to stop one junk wire from a bad parse poisoning the box. It also + /// dropped legitimately remote geometry — an outlying detail, a survey + /// point, a block placed far from the rest — so ZOOM EXTENTS quietly + /// refused to reach it. `SANE_EXTENT` and the finite test are the only + /// filters now; the ±1e6 display segments that motivated the old heuristic + /// come from XLine / Ray, which are handled explicitly below. + pub(super) fn visible_block_bounds(&self, block: Handle) -> Option<(glam::DVec3, glam::DVec3)> { + if block.is_null() { + return None; + } + let mut bounds: Option<(glam::DVec3, glam::DVec3)> = None; + macro_rules! fold { + ($pair:expr) => { + fold_sane_bounds(&mut bounds, $pair) + }; + } + + // Drawing extents must not depend on where the camera happens to be + // looking: the result is cached per geometry epoch, and a floating + // viewport's own camera is derived FROM the extents + // (`camera_for_viewport`), so reading the camera here recurses. A wire + // with no precise position falls back to the z = 0 plane. + let fallback_z = 0.0; + let scale = crate::scene::annotative::scale_handle_by_name( + &self.document, + &self.document.header.current_annotation_scale, + ); + // The resident set is the full, un-culled, LOD-free tessellation the + // block actually renders from. Culled input would fit only what is + // already on screen, so each ZOOM EXTENTS would zoom out a little and + // reveal more, converging on the true extent after several uses (#51). + let wires = self.resident_wires_for(block, None, scale, None, None); + // Infinite display segments do not contribute to drawing extents. + // Keep their base points as a fallback for otherwise empty drawings. + let mut infinite_base: Vec = Vec::new(); + for wire in wires.iter() { + let is_infinite = Self::handle_from_wire_name(&wire.name) + .and_then(|handle| self.document.get_entity(handle)) + .is_some_and(|entity| matches!(entity, EntityType::XLine(_) | EntityType::Ray(_))); + if is_infinite { + infinite_base.extend( + wire.key_vertices + .iter() + .map(|&[x, y, z]| glam::DVec3::new(x, y, z)) + .filter(|point| point.is_finite()), + ); + continue; + } + fold!(rendered_wire_bounds(std::slice::from_ref(wire), fallback_z)); + } + + // 3D solids render as meshes, not wires: a drawing of only solids has + // no wire to fit. + for (&handle, set) in &self.meshes { + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + if !self.mesh_entity_visible(handle) + || !self.belongs_to_visible_block(handle, entity.common().owner_handle, block) + { + continue; + } + let [x0, y0, x1, y1] = set.world_aabb; + let [z0, z1] = set.z_aabb; + let lo = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64); + let hi = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64); + if lo.is_finite() && hi.is_finite() { + fold!(Some((lo.min(hi), lo.max(hi)))); + } + } + + // Raster images, OLE frames and underlays draw as GPU quads; with + // their frames hidden they carry no wire outline at all. + for (&handle, image) in &self.images { + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + if !self.mesh_entity_visible(handle) + || !self.belongs_to_visible_block(handle, entity.common().owner_handle, block) + { + continue; + } + for (corner, low) in image.corners.iter().zip(image.corners_low.iter()) { + let point = glam::DVec3::new( + corner[0] as f64 + low[0] as f64, + corner[1] as f64 + low[1] as f64, + corner[2] as f64 + low[2] as f64, + ); + if point.is_finite() { + fold!(Some((point, point))); + } + } + } + + // HATCH tessellates to no wires at all (#131), so a drawing whose + // outermost geometry is a fill — or which is nothing but fills — has + // to pick its bounds up from the fill models themselves. The paper + // sheet already does this; model space did not. + // The renderer already holds the fill models for the block it is + // drawing, keyed by geometry epoch — reuse that rather than exploding + // every INSERT again on each ZOOM EXTENTS. Only a block that is not + // the one on screen (model space while a BEDIT is open) has to build + // its own set. + let hatches = if block == self.content_render_block_handle() { + // Tinted, matching the renderer's own call: the two then share one + // cache entry. Asking for an untinted set would key the same slot + // with a different selection signature and the two would evict + // each other on every frame. Tint is colour; it moves no vertex. + self.hatch_models_arc_for_view(true) + } else { + std::sync::Arc::new(self.synced_hatch_models( + block, + None, + scale, + self.annotation_all_visible(), + None, + false, + )) + }; + for hatch in hatches.iter() { + for &[x, y] in hatch.boundary.iter() { + // NaN-NaN separators between disconnected paths fall out on + // the finite test — they must not be treated as vertices. + let point = glam::DVec3::new( + hatch.world_origin[0] + x as f64, + hatch.world_origin[1] + y as f64, + 0.0, + ); + if point.is_finite() { + fold!(Some((point, point))); + } + } + } + + // Drawing holds only infinite construction geometry: fall back to its + // base points rather than reporting nothing. + if bounds.is_none() { + for point in infinite_base { + fold!(Some((point, point))); + } + } + bounds + } + pub fn fit_all(&mut self) { if self.active_viewport.is_some() { if let Some((mut min, mut max)) = self.model_space_extents() { @@ -1337,225 +1506,19 @@ impl Scene { return; } - // Use the FULL, un-culled wire set — not `entity_wires()`, which is - // frustum-culled to the current view. Culled input would fit only the - // entities already on screen, so each call would zoom out a little and - // reveal more, converging on the true extent only after several uses - // (issue #51). `wpp = None` also tessellates at a fixed tolerance so - // the bounds don't drift with zoom-adaptive curve sampling. - let layout_block = self.current_layout_block_handle(); - let scale = if self.current_layout == "Model" { - crate::scene::annotative::scale_handle_by_name( - &self.document, - &self.document.header.current_annotation_scale, - ) - } else { - self.paper_annotation_scale_handle() + let Some((min, mut max)) = self.visible_block_bounds(self.current_layout_block_handle()) + else { + // Nothing usable to fit: leave the camera where it is. + return; }; - let mut wires = self.wires_for_block_culled( - layout_block, - None, - None, - None, - None, - scale, - self.annotation_all_visible(), - None, - ); - // Infinite display segments do not contribute to drawing extents. - // Preserve their base points as a fallback for otherwise empty drawings. - let mut infinite_base_pts: Vec = Vec::new(); - wires.retain(|w| { - let is_infinite = Self::handle_from_wire_name(&w.name) - .and_then(|h| self.document.get_entity(h)) - .map(|e| matches!(e, EntityType::XLine(_) | EntityType::Ray(_))) - .unwrap_or(false); - if is_infinite { - infinite_base_pts.extend( - w.key_vertices - .iter() - .map(|v| glam::Vec3::new(v[0] as f32, v[1] as f32, v[2] as f32)), - ); - } - !is_infinite - }); - // 3D solids render as meshes, not wires, so collect their complete - // world boxes separately — a drawing of only solids has no wires to fit. - let mesh_aabbs: Vec<([f32; 4], [f32; 2])> = self - .meshes - .iter() - .filter(|(h, _)| { - let handle = **h; - self.mesh_entity_visible(handle) - && self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - layout_block, - ) - }) - }) - .map(|(_, set)| (set.world_aabb, set.z_aabb)) - .filter(|(xy, z)| { - xy[0].is_finite() - && xy[1].is_finite() - && xy[2].is_finite() - && xy[3].is_finite() - && z[0].is_finite() - && z[1].is_finite() - }) - .collect(); - // Raster images and underlays draw as GPU quads; with their frames - // hidden they carry no wire outline, so fold their corners in too. - let image_corners: Vec = self - .images - .iter() - .filter(|(h, _)| { - let handle = **h; - self.mesh_entity_visible(handle) - && self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block(handle, entity.common().owner_handle, layout_block) - }) - }) - .flat_map(|(_, image)| { - image.corners.iter().zip(image.corners_low.iter()).map(|(c, low)| { - glam::Vec3::new(c[0] + low[0], c[1] + low[1], c[2] + low[2]) - }) - }) - .filter(|p| p.is_finite()) - .collect(); - if wires.is_empty() && mesh_aabbs.is_empty() && infinite_base_pts.is_empty() && image_corners.is_empty() { - return; - } - - // Collect wire centroids for robust outlier rejection. - struct WireCent { - idx: usize, - cx: f32, - cy: f32, - } - let mut cents: Vec = Vec::with_capacity(wires.len()); - for (idx, wire) in wires.iter().enumerate() { - let mut sx = 0.0_f64; - let mut sy = 0.0_f64; - let mut n = 0_usize; - for &[x, y, _] in &wire.points { - if !x.is_finite() || !y.is_finite() { - continue; - } - sx += x as f64; - sy += y as f64; - n += 1; - } - // SDF text is carried as textured glyph quads, not ordinary wire - // points. Include those vertices so a text-only wire participates - // in the robust-centre filter used by ZOOM EXTENTS. - for vertex in &wire.text_verts { - let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64; - let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64; - if !x.is_finite() || !y.is_finite() { - continue; - } - sx += x; - sy += y; - n += 1; - } - if n > 0 { - cents.push(WireCent { - idx, - cx: (sx / n as f64) as f32, - cy: (sy / n as f64) as f32, - }); - } - } - if cents.is_empty() && mesh_aabbs.is_empty() && infinite_base_pts.is_empty() && image_corners.is_empty() { - return; - } - - // Quartile rejection needs enough samples to be meaningful. With fewer - // wires, every finite visible point belongs to the drawing extents. - let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 { - let mut xs: Vec = cents.iter().map(|c| c.cx).collect(); - let mut ys: Vec = cents.iter().map(|c| c.cy).collect(); - xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let q = |v: &[f32], frac: f32| v[((v.len() as f32 - 1.0) * frac) as usize]; - let q1x = q(&xs, 0.25); - let q3x = q(&xs, 0.75); - let q1y = q(&ys, 0.25); - let q3y = q(&ys, 0.75); - // Keep sparse annotations while rejecting isolated corrupt wires. - const K: f32 = 10.0; - let dx = (q3x - q1x).max(1.0) * K; - let dy = (q3y - q1y).max(1.0) * K; - (q1x - dx, q3x + dx, q1y - dy, q3y + dy) - } else { - ( - f32::NEG_INFINITY, - f32::INFINITY, - f32::NEG_INFINITY, - f32::INFINITY, - ) - }; - - let mut min = glam::Vec3::splat(f32::MAX); - let mut max = glam::Vec3::splat(f32::MIN); - for c in ¢s { - if c.cx < rx_lo || c.cx > rx_hi || c.cy < ry_lo || c.cy > ry_hi { - continue; - } - let wire = &wires[c.idx]; - let hw = (wire.world_width * 0.5) as f32; - for &[x, y, z] in &wire.points { - if !x.is_finite() || !y.is_finite() || !z.is_finite() { - continue; - } - if hw > 0.0 { - min = min.min(glam::Vec3::new(x - hw, y - hw, z - hw)); - max = max.max(glam::Vec3::new(x + hw, y + hw, z + hw)); - } else { - min = min.min(glam::Vec3::new(x, y, z)); - max = max.max(glam::Vec3::new(x, y, z)); - } - } - for vertex in &wire.text_verts { - let point = glam::Vec3::new( - vertex.pos[0] + vertex.pos_low[0], - vertex.pos[1] + vertex.pos_low[1], - vertex.pos[2] + vertex.pos_low[2], - ); - if point.is_finite() { - min = min.min(point); - max = max.max(point); - } - } - } - // Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject). - for ([ax, ay, bx, by], [az, bz]) in &mesh_aabbs { - min = min.min(glam::Vec3::new(*ax, *ay, *az)); - max = max.max(glam::Vec3::new(*bx, *by, *bz)); - } - for p in &image_corners { - min = min.min(*p); - max = max.max(*p); - } - // Drawing holds only infinite construction geometry: fit the view to - // its base points rather than leaving the camera unchanged. - if min.x > max.x { - for p in &infinite_base_pts { - min = min.min(*p); - max = max.max(*p); - } - } - // If no usable points found, leave the camera unchanged. - if min.x > max.x { - return; - } if min == max { - max += glam::Vec3::splat(1.0); + max += glam::DVec3::splat(1.0); } let aspect = self.active_camera_aspect(); - self.camera.borrow_mut().fit_to_bounds(min, max, aspect); + // f64 all the way into the camera: a UTM-scale drawing loses whole + // metres to an f32 round-trip, which shows up as a fit that sits + // slightly off the geometry it was asked to frame. + self.camera.borrow_mut().fit_to_bounds_f64(min, max, aspect); self.projection_bounds_epoch.set(self.geometry_epoch); self.camera_generation += 1; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 151a808bf..a44470def 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -11933,78 +11933,15 @@ vis_index={:.1} visible_probe={:.1}", fn compute_model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> { let model_block = self.model_space_block_handle(); - if model_block.is_null() { - return None; - } - // Reuse the full resident source in every layout. The kernel bounds - // its world-space key vertices; camera fitting never tessellates a - // second copy or reads the current paper sheet's wire cache. - let scale = crate::scene::annotative::scale_handle_by_name( - &self.document, - &self.document.header.current_annotation_scale, - ); - let wires = - self.resident_wires_for(model_block, Some(self.annotation_scale), scale, None, None); - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - let mut has_points = false; - - for wire in wires.iter() { - for &[x, y, z] in &wire.key_vertices { - if (x as f32).is_finite() && (y as f32).is_finite() && (z as f32).is_finite() { - min[0] = min[0].min(x); - min[1] = min[1].min(y); - min[2] = min[2].min(z); - max[0] = max[0].max(x); - max[1] = max[1].max(y); - max[2] = max[2].max(z); - has_points = true; - } - } - } - - if !self.meshes.is_empty() { - for (&handle, set) in &self.meshes { - let Some(entity) = self.document.get_entity(handle) else { - continue; - }; - if !self.mesh_entity_visible(handle) - || !self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - model_block, - ) - { - continue; - } - let [ax, ay, bx, by] = set.world_aabb; - let [az, bz] = set.z_aabb; - let (ax, ay, bx, by, az, bz) = ( - ax as f64, ay as f64, bx as f64, by as f64, az as f64, bz as f64, - ); - if (ax as f32).is_finite() - && (ay as f32).is_finite() - && (az as f32).is_finite() - && (bx as f32).is_finite() - && (by as f32).is_finite() - && (bz as f32).is_finite() - { - min[0] = min[0].min(ax.min(bx)); - min[1] = min[1].min(ay.min(by)); - min[2] = min[2].min(az.min(bz)); - max[0] = max[0].max(ax.max(bx)); - max[1] = max[1].max(ay.max(by)); - max[2] = max[2].max(az.max(bz)); - has_points = true; - } - } - } - - if has_points { - return Some(( - glam::Vec3::new(min[0] as f32, min[1] as f32, min[2] as f32), - glam::Vec3::new(max[0] as f32, max[1] as f32, max[2] as f32), - )); + // Shared with ZOOM EXTENTS so the two never disagree about what the + // drawing covers. The previous walk here read only `key_vertices`, + // which is empty for every tessellated curve (see `WireModel`) — so + // circles, arcs, ellipses and splines contributed nothing, and + // neither did hatches or images. Everything that reads drawing + // extents (plot Extents, sheet-set framing, viewport auto-fit, + // SpaceMouse model bounds) inherited that blind spot. + if let Some((min, max)) = self.visible_block_bounds(model_block) { + return Some((min.as_vec3(), max.as_vec3())); } // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; @@ -13541,42 +13478,27 @@ mod layout_cache_tests { s.add_entity(EntityType::LwPolyline(pl)); let bounds = s.model_space_extents().expect("Extents must exist"); - // Key vertices come from entities with distinct vertex positions (Line, LwPolyline). - // Line vertices: (0, 0, 10), (50, 100, 20) - // LwPolyline vertices: (300, -200, 0), (400, -100, 0) - // Min X = 0.0, Max X = 400.0 - // Min Y = -200.0, Max Y = 100.0 - // Min Z = 0.0, Max Z = 20.0 - assert!( - (bounds.0.x - 0.0).abs() < 1e-3, - "min.x mismatch: {}", - bounds.0.x - ); - assert!( - (bounds.1.x - 400.0).abs() < 1e-3, - "max.x mismatch: {}", - bounds.1.x - ); - assert!( - (bounds.0.y - (-200.0)).abs() < 1e-3, - "min.y mismatch: {}", - bounds.0.y - ); - assert!( - (bounds.1.y - 100.0).abs() < 1e-3, - "max.y mismatch: {}", - bounds.1.y - ); - assert!( - (bounds.0.z - 0.0).abs() < 1e-3, - "min.z mismatch: {}", - bounds.0.z - ); - assert!( - (bounds.1.z - 20.0).abs() < 1e-3, - "max.z mismatch: {}", - bounds.1.z - ); + // Every entity counts, curves included. The circle and the arc used to + // contribute nothing at all: the walk read only `key_vertices`, which + // is empty for anything tessellated, so the drawing looked 275 units + // narrower and 150 shorter than it is. + // Line: (0, 0, 10) .. (50, 100, 20) + // Circle: (150, 150, 0) .. (250, 250, 0) centre 200,200 r50 + // Arc: (-125, -50, -5) .. (-75, -25, -5) centre -100,-50 r25, 0..PI + // LwPolyline: (300, -200, 0) .. (400, -100, 0) + for (got, want, axis) in [ + (bounds.0.x, -125.0, "min.x"), + (bounds.1.x, 400.0, "max.x"), + (bounds.0.y, -200.0, "min.y"), + (bounds.1.y, 250.0, "max.y"), + (bounds.0.z, -5.0, "min.z"), + (bounds.1.z, 20.0, "max.z"), + ] { + assert!( + (got - want).abs() < 1e-3, + "{axis} mismatch: got {got}, want {want}" + ); + } } #[test] diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 22faca9a5..b4fc62baa 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -59,6 +59,11 @@ pub struct SelectionState { pub middle_down: bool, pub middle_last_pos: Option, pub middle_last_press_time: Option, + /// Which way a ZOOM Dynamic drag is currently zooming: `true` while it is + /// zooming out. Only the magnifier cursor reads it. The sign is latched + /// rather than taken from the live `dy` so that the sub-pixel jitter of a + /// slow drag cannot strobe the `+` / `−` glyph. + pub zoom_dir_out: bool, } /// Transient state of the open right-click context menu. diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index c6d38fa84..adde3f65d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -213,6 +213,18 @@ pub(crate) fn pick_box_aperture_px(value: i32) -> f32 { aperture.max(1.0) } +/// Which interactive navigation tool is armed. Each one owns the whole +/// viewport and hides the CAD crosshair, but they do not share a cursor: the +/// hand reads as "drag the sheet", which is wrong for a zoom or an orbit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum NavCursor { + #[default] + None, + Pan, + Orbit, + Zoom, +} + #[derive(Clone, Copy)] pub struct CrosshairOptions { pub size_percent: i32, @@ -852,7 +864,7 @@ pub fn selection_overlay<'a>( dividers: Vec, pane_move_rect: Option, pane_drop_rect: Option, - pan_mode: bool, + nav: NavCursor, suppressed: bool, hover_locked: bool, crosshair_bg: [f32; 4], @@ -882,7 +894,7 @@ pub fn selection_overlay<'a>( dividers, pane_move_rect, pane_drop_rect, - pan_mode, + nav, suppressed, hover_locked, crosshair_bg, @@ -936,9 +948,10 @@ struct SelectionCanvas { pane_move_rect: Option, /// The pane under the cursor during a pane move (drop target), highlighted. pane_drop_rect: Option, - /// Interactive PAN mode: the crosshair is hidden and the cursor becomes a - /// hand so the viewport reads as a draggable surface. - pan_mode: bool, + /// Which interactive navigation tool is armed. Any of them hides the + /// crosshair — the viewport is being driven, not drawn on — and each + /// picks its own cursor in `mouse_interaction`. + nav: NavCursor, /// A ribbon dropdown (or similar overlay) is open over the viewport. The /// crosshair is not drawn and the OS cursor is shown normally so the panel /// is usable instead of the cursor vanishing over it. (#227) @@ -1197,13 +1210,30 @@ impl canvas::Program for SelectionCanvas { if self.suppressed { return mouse::Interaction::default(); } - // PAN mode owns the whole viewport: an open hand when hovering, a - // closed hand while dragging. - if self.pan_mode && cursor.is_over(bounds) { - return if self.selection.borrow().middle_down { - mouse::Interaction::Grabbing - } else { - mouse::Interaction::Grab + // An armed navigation tool owns the whole viewport, and the cursor + // says which one: a hand to drag the sheet, four-way arrows to swing + // the model, a magnifier that carries the sign of the zoom actually + // under way. Hovering before the drag starts shows the neutral "+" + // magnifier rather than guessing a direction. + if self.nav != NavCursor::None && cursor.is_over(bounds) { + let dragging = self.selection.borrow().middle_down; + return match self.nav { + NavCursor::Pan => { + if dragging { + mouse::Interaction::Grabbing + } else { + mouse::Interaction::Grab + } + } + NavCursor::Orbit => mouse::Interaction::AllScroll, + NavCursor::Zoom => { + if dragging && self.selection.borrow().zoom_dir_out { + mouse::Interaction::ZoomOut + } else { + mouse::Interaction::ZoomIn + } + } + NavCursor::None => unreachable!("guarded above"), }; } if self.show_viewcube { @@ -1367,8 +1397,14 @@ impl canvas::Program for SelectionCanvas { ); } - if let (Some(a), Some(b)) = (self.selection.borrow().box_anchor, self.selection.borrow().box_current) { - draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme, &self.selection_visual, self.crosshair_bg); + // ZOOM Dynamic reuses `box_anchor` to remember where the drag began, + // so the marquee would otherwise rubber-band across the screen while + // the view zooms. Suppress the drawing, not the state — the anchor is + // still re-projected as the camera moves. + if self.nav != NavCursor::Zoom { + if let (Some(a), Some(b)) = (self.selection.borrow().box_anchor, self.selection.borrow().box_current) { + draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme, &self.selection_visual, self.crosshair_bg); + } } // Preview marquee for point-picked windows (STRETCH) — same look, no pick. if let Some((a, b, crossing)) = self.selection.borrow().preview_box { @@ -1826,10 +1862,10 @@ impl canvas::Program for SelectionCanvas { // arrow (see `mouse_interaction`); drawing the CAD crosshair on // top of it would double up the visual feedback. let over_divider = self.divider_under(cursor, bounds); - // PAN mode replaces the crosshair with a hand cursor. + // An armed navigation tool replaces the crosshair with its own cursor. if !over_viewcube && !over_divider - && !self.pan_mode + && self.nav == NavCursor::None && !self.suppressed && self.crosshair.cursor_type == CursorType::Crosshair { diff --git a/tests/integration.rs b/tests/integration.rs index d695e4ffb..dd6d13d09 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -72,5 +72,8 @@ mod viewport_wide_polyline_plot_width; #[path = "xclip_plot_test.rs"] mod xclip_plot_test; +#[path = "zoom_extents_coverage.rs"] +mod zoom_extents_coverage; + #[path = "zoom_extents_xline.rs"] mod zoom_extents_xline; diff --git a/tests/zoom_extents_coverage.rs b/tests/zoom_extents_coverage.rs new file mode 100644 index 000000000..455349760 --- /dev/null +++ b/tests/zoom_extents_coverage.rs @@ -0,0 +1,123 @@ +//! ZOOM EXTENTS must frame every visible entity, the way commercial solutions +//! do — no heuristic allowed to decide that part of the drawing does not count. +//! +//! Two things used to stop it. A quartile filter dropped any wire whose +//! centroid sat far from the consensus of the others, so a detail parked away +//! from the main body was silently left outside the fit. And HATCH tessellates +//! to no wires at all, so a drawing whose outermost geometry is a fill had +//! nothing for the model-space walk to read. +#![cfg(not(target_arch = "wasm32"))] + +use codec::entities::hatch::{BoundaryEdge, BoundaryPath, LineEdge}; +use codec::entities::{Hatch, Line}; +use codec::types::{Vector2, Vector3}; +use codec::EntityType; +use OpenCADStudio::scene::Scene; + +fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) { + let mut line = Line::new(); + line.start = Vector3::new(x1, y1, 0.0); + line.end = Vector3::new(x2, y2, 0.0); + scene.add_entity(EntityType::Line(line)); +} + +/// A solid square fill with corners (x, y) .. (x + size, y + size). +fn square_hatch(x: f64, y: f64, size: f64) -> Hatch { + let mut path = BoundaryPath::new(); + for (sx, sy, ex, ey) in [ + (0.0, 0.0, size, 0.0), + (size, 0.0, size, size), + (size, size, 0.0, size), + (0.0, size, 0.0, 0.0), + ] { + path.edges.push(BoundaryEdge::Line(LineEdge { + start: Vector2::new(x + sx, y + sy), + end: Vector2::new(x + ex, y + ey), + })); + } + let mut hatch = Hatch::new(); + hatch.paths.push(path); + hatch +} + +#[test] +fn fit_all_reaches_an_entity_parked_far_from_the_rest() { + let mut scene = Scene::new(); + // A dense consensus cluster — more than the eight wires the old quartile + // filter needed before it switched itself on. + for i in 0..12 { + let x = i as f64; + add_line(&mut scene, x, 0.0, x, 10.0); + } + // …and one line far outside it. Nothing about it is invalid: it is simply + // somewhere else, which is exactly what the filter could not tell apart + // from parser junk. + add_line(&mut scene, 50_000.0, 50_000.0, 50_010.0, 50_010.0); + + scene.fit_all(); + + // The fit centres on the extents box, so the target alone says whether the + // remote line counted: dropping it leaves the camera on the cluster at + // about (5.5, 5) instead of halfway out to (25005, 25005). + let cam = scene.camera.borrow(); + assert!( + (cam.target.x - 25_005.0).abs() < 50.0 && (cam.target.y - 25_005.0).abs() < 50.0, + "the fit must span cluster and remote line, target = {:?}", + cam.target + ); + assert!( + cam.ortho_size() >= 25_005.0, + "the view must be wide enough to hold both, ortho_size = {}", + cam.ortho_size() + ); +} + +#[test] +fn fit_all_frames_a_hatch_only_drawing() { + let mut scene = Scene::new(); + scene.add_entity(EntityType::Hatch(square_hatch(100.0, 200.0, 40.0))); + + scene.fit_all(); + + // Nothing else is in the drawing, so without the fill the camera never + // moves and stays on the origin. + let cam = scene.camera.borrow(); + assert!( + (cam.target.x - 120.0).abs() < 1.0 && (cam.target.y - 220.0).abs() < 1.0, + "a hatch is geometry: ZOOM EXTENTS must centre on it, target = {:?}", + cam.target + ); + assert!( + cam.ortho_size() >= 20.0, + "the view must hold the whole fill, ortho_size = {}", + cam.ortho_size() + ); +} + +#[test] +fn fit_all_keeps_a_hatch_outside_the_wires_in_view() { + let mut scene = Scene::new(); + for i in 0..12 { + let x = i as f64; + add_line(&mut scene, x, 0.0, x, 10.0); + } + // The fill is the outermost thing in the drawing; the wires alone would + // frame a 12 x 10 strip and leave it off screen. + scene.add_entity(EntityType::Hatch(square_hatch(500.0, 500.0, 50.0))); + + scene.fit_all(); + + // Wires alone centre near (5.5, 5); including the fill pulls the centre + // out to about (275, 275). + let cam = scene.camera.borrow(); + assert!( + cam.target.x > 200.0 && cam.target.y > 200.0, + "the fill must widen the fit past the wires, target = {:?}", + cam.target + ); + assert!( + cam.ortho_size() >= 275.0, + "the view must reach the far corner of the fill, ortho_size = {}", + cam.ortho_size() + ); +} From 7e12dab89e0f3ce5891d7b026d513ce0fd3c1981 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:42:38 +0300 Subject: [PATCH 06/13] fix(image): erasing a broken raster reference clears its placeholder text A raster reference that cannot be resolved is the only framed entity whose to_render emits a RenderObject::Text: the frame and its diagonals are lines, but the saved file path becomes a real glyph run in the SDF text buffer. gather_text_verts reuses the whole previous vertex buffer when text_unchanged(epoch) says no text-bearing entity moved, and that asks entity_affects_text_cache -- which enumerated Text, MText, Dimension, MultiLeader, Leader, Table, Tolerance, attributes and Insert, but not RasterImage. So an erase never raised CACHE_CATEGORY_TEXT, the stale buffer was taken as valid, and the path kept drawing over empty space. The reuse branch then re-stamps that buffer with the current epoch, which froze it until a full rebuild. CACHE_CATEGORY_IMAGE did not cover the gap: it is set from images.contains_key(handle), and a missing reference has no ImageModel at all. The same gap broke three more flows from the Added/Modified side: ATTACH of a missing reference showed no text, MOVE and grip-drag left the text behind while the frame followed, and UNDO after an erase did not bring it back. entity_affects_text_cache now counts a RasterImage whose reference does not resolve, reusing the converter's memoised resolve_image probe so the two cannot disagree. Kept a probe rather than a blanket type match so a healthy image does not rebuild every glyph in the drawing on each mouse move of a grip-drag. Four regression tests, each confirmed to fail without the fix. The main one is end-to-end on the buffer the GPU draws: before, the erased placeholder left 300 glyph vertices on screen (50 chars x 6). Also: to_render ran the same resolve probe twice; now once, shared by the outline and placeholder branches. Moved text_unchanged's doc comment off draw_ranks_stable, where it had drifted, and recorded the rule that every entity able to emit a GlyphRun must be declared there. Co-Authored-By: Claude Opus 5 --- src/entities/raster_image.rs | 27 +++---- src/scene/mod.rs | 136 +++++++++++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 19 deletions(-) diff --git a/src/entities/raster_image.rs b/src/entities/raster_image.rs index 87a51dab4..2de0fc5ff 100644 --- a/src/entities/raster_image.rs +++ b/src/entities/raster_image.rs @@ -142,9 +142,11 @@ impl RenderConvertible for RasterImage { // Diagonals are the BROKEN-reference placeholder; a resolvable image // draws its pixels inside the frame, so the X would scribble over it. - let path_probe = self.file_path.trim(); - let resolvable = path_probe.is_empty() - || crate::scene::model::image_model::resolve_image(path_probe).is_some(); + // One probe for the whole conversion: the outline below and the + // placeholder branch further down must agree on a single answer. + let path = self.file_path.trim(); + let resolvable = + path.is_empty() || crate::scene::model::image_model::resolve_image(path).is_some(); // Outline: the clip boundary, or — when it hides its inside — the // frame with the boundary as a hole. Must match the raster's own clip // triangulation in `ImageModel` so outline and pixels align. @@ -166,16 +168,15 @@ impl RenderConvertible for RasterImage { // A raster OCS can display renders its pixels (built separately) inside // this frame — just draw the frame/clip outline. A reference it cannot - // resolve (an offline/broken URL, a missing or renamed file) gets - // AutoCAD's broken-reference treatment: the frame plus the saved path - // drawn as text, so the user sees WHICH reference is unresolved instead - // of an empty box. `resolve_image` is memoised and shared with the - // raster loader, so a URL that fetches online is treated as resolvable - // (no placeholder — the image shows) while an offline one falls back to - // the path text, and neither is fetched twice. - let path = self.file_path.trim(); - let resolvable = - path.is_empty() || crate::scene::model::image_model::resolve_image(path).is_some(); + // resolve (an offline/broken URL, a missing or renamed file) gets the + // usual broken-reference treatment: the frame plus the saved path drawn + // as text, so the user sees WHICH reference is unresolved instead of an + // empty box. `resolve_image` is memoised and shared with the raster + // loader, so a URL that fetches online is treated as resolvable (no + // placeholder — the image shows) while an offline one falls back to the + // path text, and neither is fetched twice. The scene's text-cache + // classifier runs the same probe, so erasing this placeholder drops its + // glyphs from the text buffer instead of leaving them on screen. if resolvable { return Some(RenderEntity { // Picked on its frame only (shown or hidden), like the other diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 151a808bf..222a288c0 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3008,6 +3008,10 @@ impl Scene { Some(state.into_iter().collect()) } + /// Whether this entity contributes glyphs to the SDF text buffer, so a + /// change to it must invalidate the cached text vertices. Every entity whose + /// `to_render` can emit a `GlyphRun` belongs here — miss one and its glyphs + /// linger after an erase and fail to appear after an add. fn entity_affects_text_cache(&self, entity: &EntityType) -> bool { if matches!( entity, @@ -3024,6 +3028,21 @@ impl Scene { ) { return true; } + // A raster reference it cannot resolve draws the saved path as a glyph + // run in place of the picture (`RasterImage::to_render`), so its + // broken-reference placeholder lives in the text buffer; a resolvable + // one contributes only its frame. Same memoised probe the converter + // uses, so the two cannot disagree — and keeping it a probe rather than + // a blanket type match keeps a working image's grip-drag from + // rebuilding every glyph in the drawing on each mouse move. + if let EntityType::RasterImage(image) = entity { + let path = image.file_path.trim(); + if !path.is_empty() + && crate::scene::model::image_model::resolve_image(path).is_none() + { + return true; + } + } // Complex-linetype glyphs ride the host entity's wire. let lt = crate::scene::view::render::linetype_name_for(&self.document, entity); crate::io::linetypes::resolve_complex_lt(&self.document, <).is_some() @@ -3117,12 +3136,6 @@ impl Scene { true } - /// True when no text-bearing entity changed since `last_epoch`, so cached SDF - /// glyphs stay valid. Text comes from Text / MText / Dimension / MultiLeader / - /// Leader / Table / Tolerance / attributes (incl. ATTDEF) and from block - /// references (their baked text moves with the instance) — an edit to any of - /// those, or any removal, invalidates it; a plain line / arc / polyline edit - /// does not. /// Whether the per-entity draw-order labels can be replayed since /// `last_epoch`. Add/Remove keep every existing label stable; only a full /// structural delta (DRAWORDER, file/layout/block rebuild, journal overflow) @@ -3131,6 +3144,15 @@ impl Scene { self.replay_since(last_epoch).is_some() } + /// True when no text-bearing entity changed since `last_epoch`, so cached SDF + /// glyphs stay valid. Text comes from Text / MText / Dimension / MultiLeader / + /// Leader / Table / Tolerance / attributes (incl. ATTDEF), from block + /// references (their baked text moves with the instance), and from a raster + /// image whose reference cannot be resolved (it draws the saved path in place + /// of the picture) — an edit or removal of any of those invalidates it; a + /// plain line / arc / polyline edit does not. See + /// [`Scene::entity_affects_text_cache`], which classifies both directions: + /// live entities for an add/edit, the pre-erase category mask for a removal. fn text_unchanged(&self, last_epoch: u64) -> bool { self.category_cache_valid(last_epoch, CACHE_CATEGORY_TEXT, |handle| { self.document @@ -13060,6 +13082,108 @@ mod journal_tests { ); assert!(s.text_unchanged(cached_epoch)); } + + /// A raster image whose file cannot be resolved draws the saved path as a + /// glyph run instead of the picture, so erasing it MUST invalidate the SDF + /// text buffer — otherwise `gather_text_verts` reuses the previous vertex + /// list and the path text keeps drawing over the now-empty frame. + #[test] + fn erasing_broken_raster_reference_invalidates_text_cache() { + let mut s = Scene::new(); + let image = s.add_entity(EntityType::RasterImage(broken_raster("erase"))); + let cached_epoch = s.geometry_epoch; + s.erase_entities(&[image]); + + assert!( + !s.text_unchanged(cached_epoch), + "erasing a broken raster reference must drop its placeholder glyphs" + ); + } + + /// The same classification in the other direction: attaching (and, through + /// the identical Added/Modified path, moving or undoing the erase of) a + /// broken reference must invalidate the text buffer so its path text appears. + #[test] + fn attaching_broken_raster_reference_invalidates_text_cache() { + let mut s = Scene::new(); + let cached_epoch = s.geometry_epoch; + s.add_entity(EntityType::RasterImage(broken_raster("attach"))); + + assert!( + !s.text_unchanged(cached_epoch), + "attaching a broken raster reference must build its placeholder glyphs" + ); + } + + /// The converse perf property: a raster that resolves contributes no glyphs, + /// so editing or erasing it must leave the text buffer warm — a grip-drag of + /// a working image cannot re-walk every glyph in the drawing per mouse move. + /// An empty path counts as resolvable to both the converter and the + /// classifier, so this needs no picture on disk. + #[test] + fn erasing_resolvable_raster_keeps_text_cache_warm() { + use codec::entities::RasterImage; + use codec::types::Vector3; + + let mut s = Scene::new(); + let image = s.add_entity(EntityType::RasterImage(RasterImage::with_size( + "", + Vector3::new(0.0, 0.0, 0.0), + 16.0, + 16.0, + 10.0, + 10.0, + ))); + let cached_epoch = s.geometry_epoch; + s.erase_entities(&[image]); + + assert!(s.text_unchanged(cached_epoch)); + } + + /// End-to-end on the buffer the GPU actually draws: the placeholder's path + /// glyphs must be gone from the gathered SDF vertices after the erase. This + /// is the shape of the reported bug — `gather_text_verts` misses its content- + /// id cache on every geometry edit and then reuses the previous vertex list + /// whenever `text_unchanged` says the text is untouched. + #[test] + fn erasing_broken_raster_reference_drops_its_glyphs_from_the_text_buffer() { + let depth_map = rustc_hash::FxHashMap::default(); + let mut s = Scene::new(); + let image = s.add_entity(EntityType::RasterImage(broken_raster("gather"))); + + let wires = s.entity_wires(); + let before = s.gather_text_verts(&wires, 1, 7, &depth_map); + assert!( + !before.is_empty(), + "a broken raster reference must contribute its path glyphs" + ); + + s.erase_entities(&[image]); + let wires = s.entity_wires(); + let after = s.gather_text_verts(&wires, 2, 7, &depth_map); + assert!( + after.is_empty(), + "the erased placeholder left {} glyph vertices on screen", + after.len() + ); + } + + /// A raster pointing at a path that cannot exist. `resolve_image` memoises + /// per path for the life of the process, so each test uses its own path and + /// cannot inherit another's cached answer. + fn broken_raster(tag: &str) -> codec::entities::RasterImage { + use codec::entities::RasterImage; + use codec::types::Vector3; + + RasterImage::with_size( + &format!("/nonexistent-ocs-test/{tag}/missing-reference.png"), + Vector3::new(0.0, 0.0, 0.0), + 16.0, + 16.0, + 10.0, + 10.0, + ) + } } /// Delta-undo round-trips: a recorded entity-only edit must be exactly From e1318438262b674e5ac269f481b145065cab5ea8 Mon Sep 17 00:00:00 2001 From: F4brizi Date: Thu, 1 Oct 2026 18:41:12 -0300 Subject: [PATCH 07/13] feat(viewport): expand ZOOMFACTOR range to 500 --- src/app/commands/styleprops.rs | 4 ++-- src/app/mod.rs | 4 ++-- src/app/options_session.rs | 11 +++++++++++ src/app/update/file.rs | 2 +- src/app/update/mod.rs | 2 +- src/scene/view/camera.rs | 2 +- src/ui/window/options.rs | 6 +++--- 7 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 8479f551a..88a11e939 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1688,11 +1688,11 @@ impl OpenCADStudio { }, "ZOOMFACTOR" => match &value { Some(v) => match v.parse::() { - Ok(factor) if (3..=100).contains(&factor) => { + Ok(factor) if (3..=500).contains(&factor) => { self.zoom_factor = factor; Ok((format!("ZOOMFACTOR = {factor}"), true)) } - _ => Err("SETVAR: integer from 3 to 100 required.".into()), + _ => Err("SETVAR: integer from 3 to 500 required.".into()), }, None => { Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false)) diff --git a/src/app/mod.rs b/src/app/mod.rs index 65df40ec0..41a36151e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -554,7 +554,7 @@ pub(super) struct OpenCADStudio { polar_increment_deg: f32, /// Reverse the mouse-wheel zoom direction when true (ZOOMWHEEL = 1). zoom_wheel_reversed: bool, - /// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR). + /// Mouse-wheel zoom sensitivity, clamped to 3..=500 (ZOOMFACTOR). zoom_factor: i32, /// Crosshair size setting (CURSORSIZE, 1..=100). cursor_size: i32, @@ -2308,7 +2308,7 @@ pub enum Message { CommandLineFadeChanged(i32), /// Toggle reversing the mouse-wheel zoom direction (ZOOMWHEEL). ZoomWheelReversedChanged(bool), - /// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=100). + /// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=500). ZoomFactorChanged(i32), /// Options → User Preferences: right-click behaviour (SHORTCUTMENU). RightClickModeChanged(settings::RightClickMode), diff --git a/src/app/options_session.rs b/src/app/options_session.rs index b6f8d0378..d0245f64b 100644 --- a/src/app/options_session.rs +++ b/src/app/options_session.rs @@ -222,4 +222,15 @@ mod tests { assert_eq!(h.display_silhouette, silhouette); assert!(!app.tabs[i].dirty, "the drawing is as unmodified as it was"); } + + #[test] + fn zoom_factor_supports_values_above_100() { + let mut app = app(); + let _ = app.update(Message::ZoomFactorChanged(250)); + assert_eq!(app.zoom_factor, 250); + let _ = app.update(Message::ZoomFactorChanged(500)); + assert_eq!(app.zoom_factor, 500); + let _ = app.update(Message::ZoomFactorChanged(600)); + assert_eq!(app.zoom_factor, 500, "clamped to new upper limit of 500"); + } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bf54ca175..9421b6631 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -743,7 +743,7 @@ impl OpenCADStudio { self.polar_mode = s.polar; self.polar_increment_deg = s.polar_increment_deg; self.zoom_wheel_reversed = s.zoom_wheel_reversed; - self.zoom_factor = s.zoom_factor.clamp(3, 100); + self.zoom_factor = s.zoom_factor.clamp(3, 500); self.cursor_size = s.cursor_size.clamp(1, 100); self.pick_box = s.pick_box.clamp(0, 50); self.options_tab = s.options_tab; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ce1d7ccd3..b1c44f23d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8239,7 +8239,7 @@ impl OpenCADStudio { } Message::ZoomFactorChanged(factor) => { - self.zoom_factor = factor.clamp(3, 100); + self.zoom_factor = factor.clamp(3, 500); self.persist_settings_if_changed(); Task::none() } diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 4f9974491..8384732c1 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -492,7 +492,7 @@ impl Camera { } pub fn zoom(&mut self, delta: f32) { - self.distance = (self.distance * (1.0 - delta * 0.1)).max(0.001); + self.distance = (self.distance * 0.9f32.powf(delta)).max(0.001); } /// World-space offset from `target` to the point under `screen` on the diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 0e6d109f5..81b16fcbe 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -55,7 +55,7 @@ pub struct AppPrefs { pub commandline_fade_ms: i32, /// ZOOMWHEEL: reverse the mouse-wheel zoom direction. pub zoom_wheel_reversed: bool, - /// ZOOMFACTOR, 3..=100. + /// ZOOMFACTOR, 3..=500. pub zoom_factor: i32, /// TEXTEDITMODE: TEXTEDIT keeps prompting for the next object. pub texteditmode: bool, @@ -1181,10 +1181,10 @@ pub fn view_window<'a>( Space::new().height(12), row![ text(crate::t!("Zoom factor")).size(12).width(150), - slider(3..=100, prefs.zoom_factor.clamp(3, 100), Message::ZoomFactorChanged) + slider(3..=500, prefs.zoom_factor.clamp(3, 500), Message::ZoomFactorChanged) .step(1) .width(Fill), - text(prefs.zoom_factor.clamp(3, 100).to_string()).size(11).width(44), + text(prefs.zoom_factor.clamp(3, 500).to_string()).size(11).width(44), ] .spacing(10) .align_y(iced::Center), From f32d0422d2ca82ec4355c95c52d95680cf931a79 Mon Sep 17 00:00:00 2001 From: yztink Date: Thu, 1 Oct 2026 23:34:15 -0700 Subject: [PATCH 08/13] fix(script): support AutoCAD .scr scripts with multi-line, single-line, and prefix handling --- src/app/command_driver/utilities.rs | 58 ++++++++++ src/app/commands/fileops.rs | 159 ++++++++++++++++++++++++---- src/app/commands/mod.rs | 5 + src/app/mod.rs | 15 ++- src/app/update/file.rs | 8 +- src/app/update/mod.rs | 11 +- 6 files changed, 222 insertions(+), 34 deletions(-) diff --git a/src/app/command_driver/utilities.rs b/src/app/command_driver/utilities.rs index 83493ca36..7f7fb3385 100644 --- a/src/app/command_driver/utilities.rs +++ b/src/app/command_driver/utilities.rs @@ -1097,4 +1097,62 @@ impl OpenCADStudio { return task; } } + + /// Feed one line from a command script (.scr) into the command pipeline. + /// + /// If an interactive command is active, the line (or its tokens) is routed to + /// the active command's prompts (e.g. coordinates or options). If no command + /// is active, the line starts a new command (supporting both bare verbs and + /// inline single-line commands). + pub(in crate::app) fn feed_script_line(&mut self, line: &str) -> Task { + let i = self.active_tab; + let trimmed = line.trim(); + + // 1. Text editor currently open (e.g. TEXT command content step). + if self.text_inline.is_some() { + if let Some(editor) = self.text_inline.as_mut() { + editor.value = line.to_string(); + } + let committed = self.text_inline_commit(); + let task = self.post_editor_closed(committed); + return if committed { + Task::batch([task, Task::done(Message::CommandEscape)]) + } else { + task + }; + } + + // 2. Blank line submits Enter to the active command (or repeats last). + if trimmed.is_empty() { + return self.feed_command(StepInput::Enter); + } + + // 3. A command is currently active: route to its prompt handlers. + if self.tabs[i].active_cmd.is_some() { + if self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().is_free_text()) + .unwrap_or(false) + { + return self.feed_command(StepInput::Text(trimmed.to_string())); + } + + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + let mut tasks = Vec::new(); + for tok in tokens { + if self.tabs[i].active_cmd.is_none() { + // Preceding token finished the active command; remaining tokens start a new one. + tasks.push(self.run_command_line(tok)); + } else { + tasks.push(self.feed_active_cmd(tok)); + } + } + return Task::batch(tasks); + } + + // 4. No command is active: run as top-level command. + self.run_command_line(trimmed) + } } + diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 07a7f69c0..1b8a68b71 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -339,33 +339,55 @@ impl OpenCADStudio { // line is fed through the same command path the `--script` startup // flag uses. Blank rows submit Enter to the active command. "SCRIPT" | "SCR" => { - use crate::command::ValuePromptCommand; - let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:"); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); + #[cfg(not(target_arch = "wasm32"))] + { + return Some(Task::perform( + async move { + crate::sys::file_dialog() + .add_filter("Script files (*.scr)", &["scr"]) + .pick_file() + .await + .map(|h| crate::sys::handle_path(&h)) + }, + |path| match path { + Some(p) => Message::Command(format!("SCRIPT \"{}\"", p.display())), + None => Message::Noop, + }, + )); + } + #[cfg(target_arch = "wasm32")] + { + use crate::command::ValuePromptCommand; + let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:"); + self.command_line.push_info(&c.prompt()); + self.tabs[i].active_cmd = Some(Box::new(c)); + } } cmd if cmd.starts_with("SCRIPT ") || cmd.starts_with("SCR ") => { let path = cmd.split_once(' ').map(|(_, r)| r.trim().to_string()); match path { - Some(p) if !p.is_empty() => match std::fs::read_to_string(&p) { - Ok(text) => { - let cmds: Vec> = text - .lines() - .map(str::trim) - .filter(|l| !l.starts_with('#') && !l.starts_with(';')) - .map(|l| Task::done(Message::ScriptLine(l.to_string()))) - .collect(); - self.command_line.push_output(crate::tf!( - "SCRIPT: running {} command(s) from {p}.", - cmds.len() - ).as_ref()); - return Some(Task::batch(cmds)); + Some(p) if !p.is_empty() => { + let clean_path = p.trim().trim_matches(|c| c == '"' || c == '\''); + match std::fs::read_to_string(clean_path) { + Ok(text) => { + let cmds: Vec> = text + .lines() + .map(str::trim) + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) + .map(|l| Task::done(Message::ScriptLine(l.to_string()))) + .collect(); + self.command_line.push_output(crate::tf!( + "SCRIPT: running {} command(s) from {clean_path}.", + cmds.len() + ).as_ref()); + return Some(Task::batch(cmds)); + } + Err(e) => { + self.command_line + .push_error(crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref()); + } } - Err(e) => { - self.command_line - .push_error(crate::tf!("SCRIPT: cannot read {p}: {e}").as_ref()); - } - }, + } _ => { self.command_line .push_info(crate::t!("Usage: SCRIPT ").as_ref()); @@ -418,3 +440,96 @@ fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> { } None } + +#[cfg(test)] +mod tests { + use crate::app::{Message, OpenCADStudio}; + + fn fresh_app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn multiline_script_draws_line() { + let mut app = fresh_app(); + let i = app.active_tab; + let before_count = app.tabs[i].scene.document.entities().count(); + + // Feed standard multi-line CAD script sequence + let _ = app.update(Message::ScriptLine("LINE".into())); + assert!(app.tabs[i].active_cmd.is_some(), "LINE command should be active"); + + let _ = app.update(Message::ScriptLine("0,0".into())); + assert!(app.tabs[i].active_cmd.is_some(), "LINE command should stay active after first point"); + + let _ = app.update(Message::ScriptLine("10,10".into())); + let _ = app.update(Message::ScriptLine("".into())); // Enter to finish + + let after_count = app.tabs[i].scene.document.entities().count(); + assert_eq!(after_count, before_count + 1, "Line entity should have been created"); + assert!(app.tabs[i].active_cmd.is_none(), "LINE command should be finished"); + } + + #[test] + fn single_line_script_still_works() { + let mut app = fresh_app(); + let i = app.active_tab; + let before_count = app.tabs[i].scene.document.entities().count(); + + let _ = app.update(Message::ScriptLine("CIRCLE 5,5 2".into())); + + let after_count = app.tabs[i].scene.document.entities().count(); + assert_eq!(after_count, before_count + 1, "Circle entity should have been created"); + assert!(app.tabs[i].active_cmd.is_none(), "CIRCLE command should be finished"); + } + + #[test] + fn autodesk_command_prefixes_strip_underscore_and_dot() { + let mut app = fresh_app(); + let i = app.active_tab; + let before_count = app.tabs[i].scene.document.entities().count(); + + let _ = app.update(Message::ScriptLine("_CIRCLE 10,10 3".into())); + + let after_count = app.tabs[i].scene.document.entities().count(); + assert_eq!(after_count, before_count + 1, "_CIRCLE should resolve to CIRCLE and create entity"); + } + + #[test] + fn test_script_scr_draws_all_entities() { + let mut app = fresh_app(); + let i = app.active_tab; + const SCRIPT: &str = r#" +_LINE +0,0 +100,0 +100,100 +0,100 +C + +_LINE +0,0 +100,100 + +_LINE +0,100 +100,0 + +CIRCLE +50,50 +35 + +CIRCLE 50,50 15 + +ZOOM EXTENTS +"#; + for line in SCRIPT.lines().map(str::trim).filter(|l| !l.starts_with('#') && !l.starts_with(';')) { + let _ = app.update(Message::ScriptLine(line.to_string())); + } + // 4 square edges + 2 diagonal lines + 2 circles = 8 entities + assert_eq!(app.tabs[i].scene.document.entities().count(), 8); + } +} + diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 55cd5f817..06ef404dc 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -113,6 +113,11 @@ impl OpenCADStudio { Some(rest) => (rest.trim(), true), None => (cmd, false), }; + let cmd = cmd.trim(); + // AutoCAD command prefixes: '_' (international name), '.' (built-in name bypass) + let cmd = cmd.strip_prefix('.').unwrap_or(cmd); + let cmd = cmd.strip_prefix('_').unwrap_or(cmd); + let cmd = cmd.strip_prefix('.').unwrap_or(cmd); let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); if is_spacemouse_command(cmd) { diff --git a/src/app/mod.rs b/src/app/mod.rs index 1602c6392..371ba62eb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1294,6 +1294,7 @@ pub(super) struct OpenCADStudio { /// drawings in a file manager produces exactly that (one process per file, /// all arriving at once), which makes this queue load-bearing, not polish. pub(super) pending_opens: std::collections::VecDeque, + pub(super) pending_startup_script_lines: Vec, /// One global interaction-index build at a time. Large drawings can each /// hold millions of entries, so file-open bursts must not multiply peak /// CPU and memory by the number of tabs. @@ -4278,6 +4279,7 @@ impl OpenCADStudio { missing_fonts_downloading: false, suppressed_missing_fonts: rustc_hash::FxHashSet::default(), pending_opens: std::collections::VecDeque::new(), + pending_startup_script_lines: Vec::new(), active_interaction_index: None, queued_interaction_indices: std::collections::VecDeque::new(), pending_close: None, @@ -4624,21 +4626,26 @@ impl OpenCADStudio { s.command_line.push_warning(¬ice); crate::scene::pipeline::report_gpu_line(&format!("[gpu] {notice}")); } - let cli_open: Task = if !cfg.files.is_empty() { + let has_files = !cfg.files.is_empty(); + let cli_open: Task = if has_files { Task::batch( cfg.files .into_iter() .map(|p| Task::done(Message::OpenExternal(p))), ) - } else if cfg.new { + } else if cfg.new || !cfg.script_lines.is_empty() { Task::done(Message::TabNew) } else { Task::none() }; - // Startup command script: each line dispatched as if typed at the - // command line, in order, after any file open is requested. + // Startup command script: if files were passed to open, defer the + // script lines until the document finishes opening. Otherwise, dispatch + // them on the newly created drawing tab. let script: Task = if cfg.script_lines.is_empty() { Task::none() + } else if has_files { + s.pending_startup_script_lines = cfg.script_lines; + Task::none() } else { Task::batch( cfg.script_lines diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7d9455805..c90563426 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1976,7 +1976,13 @@ impl OpenCADStudio { } self.drain_pending_open() }; - Task::batch([thumbs_task, pending_open_task, interaction_task]) + let startup_script_task = if !self.pending_startup_script_lines.is_empty() { + let lines = std::mem::take(&mut self.pending_startup_script_lines); + Task::batch(lines.into_iter().map(|l| Task::done(Message::ScriptLine(l)))) + } else { + Task::none() + }; + Task::batch([thumbs_task, pending_open_task, interaction_task, startup_script_task]) } pub(super) fn on_wblock_save_result_some( diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 3d0dd18ef..2918d7886 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -982,6 +982,9 @@ impl OpenCADStudio { .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); + if ext == "scr" { + return self.dispatch_command(&format!("SCRIPT \"{}\"", path.display())); + } if !matches!(ext.as_str(), "dwg" | "dxf" | "bak" | "sv$") { self.command_line.push_error( crate::tf!("Unsupported file type: {}", path.display()).as_ref(), @@ -2506,13 +2509,7 @@ impl OpenCADStudio { self.dispatch_command(&cmd) } - Message::ScriptLine(line) => { - if line.trim().is_empty() { - self.feed_command(crate::command::StepInput::Enter) - } else { - self.run_command_line(&line) - } - } + Message::ScriptLine(line) => self.feed_script_line(&line), Message::ToggleLayers => { if self.active_modal == Some(super::ModalKind::Layers) { From 65347de9040d2343e3c11957466ee5ed82306d1e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 2 Oct 2026 10:02:51 +0300 Subject: [PATCH 09/13] fix(commands): strip _/. name prefixes only when the name as written is unknown #1584 stripped the prefixes before dispatch, which turned the ribbon's own internal names (_PCCROPPOLY, _XREFEDIT, _PDFULSNAP, ...) into unknown commands. A name is now tried as written and retried without its prefixes only when no family handles it; the Start-tab gate judges the bare name. Co-Authored-By: Claude Opus 5.5 --- src/app/command_driver/utilities.rs | 1 - src/app/commands/fileops.rs | 3 +-- src/app/commands/mod.rs | 23 +++++++++++++++++------ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/app/command_driver/utilities.rs b/src/app/command_driver/utilities.rs index 39ad731c1..f80076737 100644 --- a/src/app/command_driver/utilities.rs +++ b/src/app/command_driver/utilities.rs @@ -1197,4 +1197,3 @@ impl OpenCADStudio { self.run_command_line(trimmed) } } - diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 57bc226ae..4dd2ed284 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -489,7 +489,7 @@ mod tests { } #[test] - fn autodesk_command_prefixes_strip_underscore_and_dot() { + fn command_name_prefixes_are_stripped() { let mut app = fresh_app(); let i = app.active_tab; let before_count = app.tabs[i].scene.document.entities().count(); @@ -535,4 +535,3 @@ ZOOM EXTENTS assert_eq!(app.tabs[i].scene.document.entities().count(), 8); } } - diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index efd730b0f..cdd87f134 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -116,11 +116,6 @@ impl OpenCADStudio { Some(rest) => (rest.trim(), true), None => (cmd, false), }; - let cmd = cmd.trim(); - // AutoCAD command prefixes: '_' (international name), '.' (built-in name bypass) - let cmd = cmd.strip_prefix('.').unwrap_or(cmd); - let cmd = cmd.strip_prefix('_').unwrap_or(cmd); - let cmd = cmd.strip_prefix('.').unwrap_or(cmd); let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); if is_spacemouse_command(cmd) { @@ -201,7 +196,7 @@ impl OpenCADStudio { // editors (shortcuts, aliases) — none of them read the scene. This is // the single place that decides; `on_ribbon_tool_click` defers to it // rather than keeping a second, blunter copy (#388, #389). - if self.tabs[i].is_start && !start_allowed(cmd) { + if self.tabs[i].is_start && !start_allowed(strip_command_prefixes(cmd).unwrap_or(cmd)) { self.command_line .push_info(crate::t!("No drawing open. Use NEW or OPEN to start a drawing.").as_ref()); return Task::none(); @@ -233,6 +228,15 @@ impl OpenCADStudio { return t; } + // Scripts and typed input may carry the international (`_`) and + // built-in (`.`) name prefixes (`_LINE`, `._LINE`). Ribbon tools + // dispatch internal names that start with `_` themselves + // (`_PCCROPPOLY`), so a name is tried as written first and loses its + // prefixes only when nothing handles it. + if let Some(bare) = strip_command_prefixes(cmd) { + return self.dispatch_command_inner(bare, allow_suggest); + } + // No family matched. From the interactive command line, run the // closest autocomplete suggestion instead of erroring, so a partial // command completes on Enter (`BAC` → `BACKGROUND`). The verb's own @@ -1036,3 +1040,10 @@ mod marquee_cancel_tests { ); } } + +/// `cmd` without its leading `_` / `.` name prefixes (`_LINE`, `.LINE`, +/// `._LINE`), or `None` when it carries none. +fn strip_command_prefixes(cmd: &str) -> Option<&str> { + let bare = cmd.trim_start_matches(['_', '.']); + (bare.len() != cmd.len() && !bare.is_empty()).then_some(bare) +} From 09cd50ea627627654fd9a947d05d40d09e74fb3e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 2 Oct 2026 10:06:10 +0300 Subject: [PATCH 10/13] fix(trim): polyline vertices are not cutting edges; self-cuts only as an edge #1567 made every vertex a trim limit, so a polyline no edge crosses lost the clicked segment and a trimmed piece stopped at the next corner instead of running on to the next cutting edge. Vertices are no cuts again, a polyline with no cut is left alone, and its own crossings cut it only when it is among the cutting edges. The crossing window's unused pick corner is dropped. Co-Authored-By: Claude Opus 5.5 --- src/modules/draw/modify/trim.rs | 53 +++++++++++---------------------- 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index ecb0e3eb8..130151dad 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -1170,10 +1170,11 @@ fn lwpoly_segment_curve( /// polyline intersect. /// /// Segment neighbours are deliberately excluded: their common vertex is a -/// normal polyline vertex, not a self-intersection. -fn lwpoly_self_cut_params(poly: &LwPolyline) -> Vec { +/// normal polyline vertex, not a self-intersection. A polyline cuts itself +/// only when it is one of the cutting edges. +fn lwpoly_self_cut_params(poly: &LwPolyline, geos: &[Geo]) -> Vec { let n = poly.vertices.len(); - if n < 3 { + if n < 3 || !geos.iter().any(|geo| geo.handle() == poly.common.handle) { return Vec::new(); } @@ -1286,7 +1287,6 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< poly.vertices[i % n].bulge }; - // Boundary cuts as global params (segment index + local u). // External boundary cuts as global params (segment index + local u). let mut cuts: Vec = Vec::new(); @@ -1307,25 +1307,16 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< } // A polyline can also cut itself. These are intersections between - // non-adjacent segments belonging to this same LwPolyline. - cuts.extend(lwpoly_self_cut_params(poly)); - - // Every polyline vertex is also a valid trim boundary. This allows an entire - // segment between two vertices to be removed even when no external entity - // crosses that particular segment. - if closed { - for i in 0..seg_count { - cuts.push(i as f64); - } - } else { - // 0 and `total` are added explicitly below as the open polyline ends. - for i in 1..seg_count { - cuts.push(i as f64); - } - } + // non-adjacent segments belonging to this same LwPolyline. Its vertices + // are not cuts: the trimmed piece runs on past corners to the next + // cutting edge, and a polyline no edge crosses is left alone. + cuts.extend(lwpoly_self_cut_params(poly, geos)); cuts.sort_by(f64::total_cmp); cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + if cuts.is_empty() { + return None; + } // Click param: nearest point on the polyline. let mut best = (f64::INFINITY, 0.0_f64); @@ -1430,7 +1421,11 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< } } - Some(out) + if out.is_empty() { + None + } else { + Some(out) + } } // ── Extend helpers ──────────────────────────────────────────────────────── @@ -1764,9 +1759,6 @@ enum TrimMode { struct CrossingWindow { min: [f64; 2], max: [f64; 2], - /// The first corner is the trim-side hint, matching the side from which - /// the crossing window was dragged. - pick: [f64; 2], } fn segment_window_range( @@ -1828,7 +1820,7 @@ fn crossing_trim_lwpolyline( // Compute the polyline's own non-adjacent segment intersections once. // These global parameters will later be mapped back into each source segment. - let self_cuts = lwpoly_self_cut_params(poly); + let self_cuts = lwpoly_self_cut_params(poly, geos); for i in 0..seg_count { let a = vertex_xy(i); @@ -1859,7 +1851,6 @@ fn crossing_trim_lwpolyline( continue; }; // External intersections on this segment. - // External intersections on this segment. let mut cuts = polyline_seg_ts(a, b, bulge, handle, geos); // Also include self-intersections belonging to this source segment. @@ -2923,11 +2914,7 @@ impl CadCommand for TrimCommand { if !matches!(self.mode, TrimMode::Pick) || fence.len() < 2 { return None; } - let window = window.map(|(min, max)| CrossingWindow { - min, - max, - pick: fence[0], - }); + let window = window.map(|(min, max)| CrossingWindow { min, max }); let replacements = fence_pass( &self.all_entities, &self.geos, @@ -3307,7 +3294,6 @@ impl CadCommand for TrimCommand { let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], - pick: p1, }; self.fence_run(&rect, Some(window)) } @@ -3339,7 +3325,6 @@ impl CadCommand for TrimCommand { let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], - pick: p1, }; out.extend(fence_result_preview( &self.all_entities, @@ -3773,7 +3758,6 @@ impl CadCommand for ExtendCommand { let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], - pick: p1, }; self.fence_run(&rect, Some(window)) } @@ -3805,7 +3789,6 @@ impl CadCommand for ExtendCommand { let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], - pick: p1, }; out.extend(fence_result_preview( &self.all_entities, From 717ae25b6708ba4e57a21661f3fddaa6a0517c39 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 2 Oct 2026 10:07:47 +0300 Subject: [PATCH 11/13] fix(bench): pass the navigation cursor to selection_overlay #1577 replaced its pan-mode flag with NavCursor; the overlay benchmark still passed a bool and no longer compiled. Co-Authored-By: Claude Opus 5.5 --- benches/performance_benchmarks.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index ffae1f7a2..2cc078a0d 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -58,7 +58,7 @@ use OpenCADStudio::snap::Snapper; use OpenCADStudio::ui::icons::{self, CHECK}; use OpenCADStudio::ui::overlay::{ grid_segments, selection_overlay, should_reuse, CrosshairOptions, GridCanvasState, - GridKey, GridParams, GridStyle, GripMarker, OstTrackPoint, SelectionVisualOptions, + GridKey, GridParams, GridStyle, GripMarker, NavCursor, OstTrackPoint, SelectionVisualOptions, UcsIconParams, }; use OpenCADStudio::ui::properties::LinetypeItem; @@ -1512,7 +1512,7 @@ fn build_selection_overlay_element( vec![], None, None, - false, + NavCursor::None, false, false, [0.1, 0.1, 0.1, 1.0], From 369dc93029b135188f2280b56e2b3d9a99c63bf1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 2 Oct 2026 10:15:43 +0300 Subject: [PATCH 12/13] fix(blocks-palette): message placeholders, SVG arrows, tests follow the new palette #1573's five formatted block messages carried {}/{name} in every locale, so the catalog never filled them and the UI printed the brace text; they use the __ocs_fmt_0__ marker now. The Options collapse arrow, the library up button and the folder picker are SVG icons instead of glyph text, and the Details header drops its sort glyph. The contrast test follows the cards to the weak surface pair and the palette test closes with BLOCKSPALETTECLOSE, since BLOCKSPALETTE now only opens. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 10 +++++----- locales/bg-BG/opencadstudio.ftl | 10 +++++----- locales/cs-CZ/opencadstudio.ftl | 10 +++++----- locales/de-DE/opencadstudio.ftl | 10 +++++----- locales/el-GR/opencadstudio.ftl | 10 +++++----- locales/en-US/opencadstudio.ftl | 10 +++++----- locales/es-ES/opencadstudio.ftl | 10 +++++----- locales/fi-FI/opencadstudio.ftl | 10 +++++----- locales/fr-FR/opencadstudio.ftl | 10 +++++----- locales/hi-IN/opencadstudio.ftl | 10 +++++----- locales/hu-HU/opencadstudio.ftl | 10 +++++----- locales/it-IT/opencadstudio.ftl | 10 +++++----- locales/ja-JP/opencadstudio.ftl | 10 +++++----- locales/ko-KR/opencadstudio.ftl | 10 +++++----- locales/nl-NL/opencadstudio.ftl | 10 +++++----- locales/pl-PL/opencadstudio.ftl | 10 +++++----- locales/pt-BR/opencadstudio.ftl | 10 +++++----- locales/ru-RU/opencadstudio.ftl | 10 +++++----- locales/zh-CN/opencadstudio.ftl | 10 +++++----- locales/zh-TW/opencadstudio.ftl | 10 +++++----- src/app/commands/blocks.rs | 3 +++ src/ui/theme_accessibility_tests.rs | 16 +++++++++------- src/ui/window/block_palette.rs | 20 ++++++++++++++++---- 23 files changed, 128 insertions(+), 111 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index b11b307a2..f9ece4f80 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4061,11 +4061,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index c52fbc5d0..149f38f7f 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -4048,11 +4048,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index a9b707039..7f9d67194 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -4046,11 +4046,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 9d6a85a0c..2769b2226 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -4047,11 +4047,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index af55a9273..5fae59093 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -4049,11 +4049,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 8d0d15a28..cbfcec4e0 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4050,11 +4050,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index e9aeb37f4..be4d18753 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4049,11 +4049,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 653040ffb..12479acc7 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -4048,11 +4048,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index e65125269..f0076e9a9 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -4049,11 +4049,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 5f35ca1ae..e14630509 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -4037,11 +4037,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index b3fca5218..6c749cc99 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -4048,11 +4048,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index f933595f0..5b52396a1 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -4048,11 +4048,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index afba393bd..38fbc8b7f 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -4037,11 +4037,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 5fcd3d4e1..da07beb69 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -4048,11 +4048,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 495fd45f3..512ce3b02 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -4047,11 +4047,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 87db5e698..266bb9e43 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -4048,11 +4048,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index fc8d3184d..c999e0621 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -4049,11 +4049,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index af45f94bc..534b63b84 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4050,11 +4050,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = Создание .boolean = Булевы операции diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 9c7572400..51b704258 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4032,11 +4032,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index ff26b0b01..244e35380 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -4044,11 +4044,11 @@ insert = .bp-select-library = Select a Block Library Folder .bp-view-small = Small Icons .bp-on-screen = specified on screen - .bp-block-not-found = Block "{}" not found. - .bp-block-redefined = Block "{name}" redefined. - .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. - .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. - .bp-block-removed = Block "{name}" removed. + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 9f9cc1aea..4c10b5e5f 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -1770,7 +1770,10 @@ mod tests { app.dock_expanded == Some(crate::ui::dock::PanelId::BlockPalette), "palette must open expanded, not as the collapsed bar" ); + // BLOCKSPALETTE only opens; BLOCKSPALETTECLOSE closes. let _ = app.run_command_line("BLOCKSPALETTE"); + assert!(app.show_block_palette); + let _ = app.run_command_line("BLOCKSPALETTECLOSE"); assert!(!app.show_block_palette); } diff --git a/src/ui/theme_accessibility_tests.rs b/src/ui/theme_accessibility_tests.rs index f8e6caecf..8eb9ce90b 100644 --- a/src/ui/theme_accessibility_tests.rs +++ b/src/ui/theme_accessibility_tests.rs @@ -480,14 +480,16 @@ fn test_block_palette_contrast() { // a hardcoded foreground, these equality checks fail on every theme // whose surface text is not pure white. let (normal_bg, normal_fg) = block_card_colors(theme, false, BtnStatus::Active); + // Cards sit on the dock's base surface, so they take the weak pair + // to read as cards. assert_eq!( - normal_bg, p.background.base.color, - "Theme {:?} block card normal background drifted from base pair", + normal_bg, p.background.weak.color, + "Theme {:?} block card normal background drifted from weak pair", theme ); assert_eq!( - normal_fg, p.background.base.text, - "Theme {:?} block card normal label must use base text (was hardcoded WHITE)", + normal_fg, p.background.weak.text, + "Theme {:?} block card normal label must use weak text (was hardcoded WHITE)", theme ); @@ -505,11 +507,11 @@ fn test_block_palette_contrast() { theme ); - // Default-branch statuses (Active/Disabled/Focused/...) share the base pair. + // Default-branch statuses (Active/Disabled/Focused/...) share the weak pair. let (_, disabled_fg) = block_card_colors(theme, false, BtnStatus::Disabled); assert_eq!( - disabled_fg, p.background.base.text, - "Theme {:?} block card default-state label must use base text", + disabled_fg, p.background.weak.text, + "Theme {:?} block card default-state label must use weak text", theme ); diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index 516062984..17527437a 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -587,7 +587,11 @@ fn options_card<'a>(palette: &'a BlockPalette) -> Element<'a, Message> { row![ text(crate::t!("Options").to_uppercase()).size(10).style(accent_text), Space::new().width(Fill), - text(if palette.options_collapsed { "▸" } else { "▾" }).size(22), + if palette.options_collapsed { + crate::ui::icons::themed_arrow_right(12.0) + } else { + crate::ui::icons::themed_arrow_down(12.0) + }, ] .align_y(iced::Center), ) @@ -705,7 +709,10 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< .text_size(11) .padding([3, 6]) .width(Fill), - button(text("…").size(12)).on_press(pm(BlockPaletteMsg::LibraryBrowse)).style(button_style(false)).padding([3, 8]), + button(crate::ui::icons::themed(crate::ui::icons::FOLDER_OPEN, 14.0)) + .on_press(pm(BlockPaletteMsg::LibraryBrowse)) + .style(button_style(false)) + .padding([3, 8]), ] .spacing(4) .align_y(iced::Center); @@ -713,7 +720,12 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< palette.libraries.first().is_some_and(|root| d != &PathBuf::from(root)) }); if in_sub { - lib_row = lib_row.push(button(text("↑").size(12)).on_press(pm(BlockPaletteMsg::LibraryUp)).style(button_style(false)).padding([3, 8])); + lib_row = lib_row.push( + button(crate::ui::icons::themed_arrow_up(12.0)) + .on_press(pm(BlockPaletteMsg::LibraryUp)) + .style(button_style(false)) + .padding([3, 8]), + ); } top = top.push(lib_row); if let (Some(root), Some(dir)) = (palette.libraries.first(), palette.library_dir.as_ref()) { @@ -767,7 +779,7 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< col = col.push( row![ text(crate::t!("Icon")).size(11).style(muted_style).width(Length::Fixed(40.0)), - text(format!("{} ▲", crate::t!("Name"))).size(11).style(muted_style).width(Fill), + text(crate::t!("Name")).size(11).style(muted_style).width(Fill), text(crate::t!("Annotative / Dynamic")).size(11).style(muted_style).width(Length::Fixed(140.0)), ] .spacing(4), From 558b5f6526aaae3f519cc2ccb4c83b5703ade897 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 2 Oct 2026 10:20:34 +0300 Subject: [PATCH 13/13] fix(attdef): SVG dialog buttons, upper-case tags from Properties, Cargo.toml blank line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1574's dialog drew its pick, close, field and editor buttons with glyph text (⌖ × ƒ …) that renders as tofu where the font lacks it; they use the snap and close SVG icons and plain ASCII now. A tag set from Properties is stored upper case like one from the dialogs, and the stray blank line the PR left in Cargo.toml is gone. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 1 - src/entities/attribute.rs | 2 +- src/ui/window/attdef_dialog.rs | 27 ++++++++++++++++++--------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5ced7c8f8..8ebca382d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,7 +94,6 @@ objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString" iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } - [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index aa382cc08..43d87f683 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -713,7 +713,7 @@ impl PropertyEditable for AttributeDefinition { return; } match field { - "att_tag" => self.tag = value.trim().to_string(), + "att_tag" => self.tag = value.trim().to_uppercase(), "att_prompt" if !self.flags.constant => self.prompt = value.to_string(), "att_default" => { self.default_value = value.to_string(); diff --git a/src/ui/window/attdef_dialog.rs b/src/ui/window/attdef_dialog.rs index c74c81f08..df548fcf8 100644 --- a/src/ui/window/attdef_dialog.rs +++ b/src/ui/window/attdef_dialog.rs @@ -116,8 +116,12 @@ fn input<'a>(value: &str, enabled: bool, on: fn(String) -> AttdefDialogMsg) -> E } /// The small button beside a field that takes the value from the drawing. -fn pick<'a>(glyph: &'static str, tip: String, message: Option) -> Element<'a, Message> { - let b = button(text(glyph).size(13).align_x(iced::Center).width(Fill)) +fn pick<'a>( + face: Element<'a, Message>, + tip: String, + message: Option, +) -> Element<'a, Message> { + let b = button(container(face).center_x(Fill)) .style(button_style(false)) .padding([4, 0]) .width(Length::Fixed(28.0)); @@ -146,7 +150,7 @@ fn error_band<'a>(error: &Option) -> Option> { crate::ui::icons::semantic(ICON_WARN, 14.0), text(error).size(11), Space::new().width(Fill), - button(text("×").size(12)) + button(crate::ui::icons::themed(crate::ui::icons::CLOSE, 10.0)) .on_press(msg(AttdefDialogMsg::DismissError)) .style(button_style(false)) .padding([1, 6]), @@ -165,6 +169,11 @@ fn error_band<'a>(error: &Option) -> Option> { ) } +/// The face of the buttons that take a value from the drawing. +fn screen_pick<'a>() -> Element<'a, Message> { + crate::ui::icons::themed(crate::ui::icons::SNAP, 14.0) +} + static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg"); fn footer<'a>(action: std::borrow::Cow<'static, str>, ok: AttdefDialogMsg) -> Element<'a, Message> { @@ -234,13 +243,13 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz let mut value = row![input(&state.default, true, AttdefDialogMsg::Default)].spacing(6); if multiline { value = value.push(pick( - "…", + text("...").size(13).into(), t!("Multiline editor").into_owned(), Some(AttdefDialogMsg::EditValue), )); } // No field picker in this application yet. - value.push(pick("ƒ", t!("Insert field").into_owned(), None)) + value.push(pick(text("fx").size(12).into(), t!("Insert field").into_owned(), None)) } .spacing(6) .into(), @@ -293,7 +302,7 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz t!("Text height").into_owned(), row![ input(&state.height, size_on, AttdefDialogMsg::Height), - pick("⌖", t!("Specify on-screen").into_owned(), size_on.then_some(AttdefDialogMsg::PickHeight)), + pick(screen_pick(), t!("Specify on-screen").into_owned(), size_on.then_some(AttdefDialogMsg::PickHeight)), ] .spacing(6) .into(), @@ -303,7 +312,7 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz t!("Rotation").into_owned(), row![ input(&state.rotation, rot_on, AttdefDialogMsg::Rotation), - pick("⌖", t!("Specify on-screen").into_owned(), rot_on.then_some(AttdefDialogMsg::PickRotation)), + pick(screen_pick(), t!("Specify on-screen").into_owned(), rot_on.then_some(AttdefDialogMsg::PickRotation)), ] .spacing(6) .into(), @@ -314,7 +323,7 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz row![ input(&state.width, text_on && multiline, AttdefDialogMsg::Width), pick( - "⌖", + screen_pick(), t!("Specify on-screen").into_owned(), (text_on && multiline).then_some(AttdefDialogMsg::PickWidth) ), @@ -372,7 +381,7 @@ pub fn view_edit<'a>(state: &'a AttdefEditState, sizing: crate::ui::modal::Modal true, AttdefDialogMsg::EditDefault ), - pick("ƒ", t!("Insert field").into_owned(), None), + pick(text("fx").size(12).into(), t!("Insert field").into_owned(), None), ] .spacing(6) .align_y(iced::Center),