diff --git a/Cargo.lock b/Cargo.lock index 5cbecfa82..1f5a6b495 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=d8258b2768c781f5318acbc2126ddcd1664e21d2#d8258b2768c781f5318acbc2126ddcd1664e21d2" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=1e19ad4e4d35cc04384bf72bc9c6d0445876c2d9#1e19ad4e4d35cc04384bf72bc9c6d0445876c2d9" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index cd58c11c1..fe53217f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a0f7d444f1607bc4b2c881060cbe7ea1014253cb", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d8258b2768c781f5318acbc2126ddcd1664e21d2", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "1e19ad4e4d35cc04384bf72bc9c6d0445876c2d9", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/mod.rs b/src/app/mod.rs index 91034b4e9..173ac41a8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -960,6 +960,20 @@ pub(super) struct OpenCADStudio { shortcut_bindings: rustc_hash::FxHashMap, /// Working rows shown by the shortcut editor until Apply is pressed. shortcut_editor_rows: Vec<(String, String)>, + /// Row whose Key cell is armed for capture: the next key combination + /// pressed anywhere fills that row's key. While set, the keyboard + /// subscription swallows every key so captured shortcuts do not run in + /// the drawing. + shortcut_capture_row: Option, + /// True while a freshly added (top) row is an unfinished draft: it exists + /// only until its key and command are filled (success) or it is cancelled + /// (Esc / ✕ / dialog close), so the list never keeps an empty row. + shortcut_pending_add: bool, + /// True while the "Reset to default" confirmation is showing. + shortcut_reset_confirm: bool, + /// True while the "unsaved changes will be discarded" confirmation + /// overlays the editor: the user tried to close with un-applied rows. + shortcut_close_confirm: bool, // ── Command Aliases ─────────────────────────────────────────────────── /// Command-line aliases: uppercase abbreviation → uppercase command @@ -2552,6 +2566,28 @@ pub enum Message { ShortcutEditorAdd, ShortcutEditorRemove(usize), ShortcutEditorApply, + /// Apply the working rows and close the dialog. + ShortcutEditorApplyExit, + /// The check button on a draft row: finish the addition without applying. + ShortcutEditorDraftAccept, + /// Show the "Reset to default" confirmation. + ShortcutEditorResetAsk, + /// Reset bindings and rows to the shipped defaults. + ShortcutEditorResetConfirm, + /// Hide the reset confirmation without resetting. + ShortcutEditorResetDeny, + /// Discard un-applied rows and close the editor. + ShortcutEditorCloseDiscard, + /// Keep editing: hide the discard confirmation. + ShortcutEditorCloseKeep, + /// The user clicked a row's Key cell: arm capture for that row. + ShortcutCaptureStart(usize), + /// A key combination was pressed while a row's Key cell was armed. + ShortcutCaptureKey(String), + /// Disarm capture without filling the row. + ShortcutCaptureClear, + /// Cancel capture (Esc): also discards an unfinished draft row. + ShortcutCaptureCancel, /// Canonical key emitted by the global keyboard subscription. ShortcutPressed(String), // ── Command Alias Editor (ALIASEDIT) ──────────────────────────────── @@ -3414,6 +3450,10 @@ impl OpenCADStudio { // Keyboard shortcuts shortcut_bindings: rustc_hash::FxHashMap::default(), shortcut_editor_rows: Vec::new(), + shortcut_capture_row: None, + shortcut_pending_add: false, + shortcut_reset_confirm: false, + shortcut_close_confirm: false, // Command aliases (populated from ocad.pgp just after construction) command_aliases: rustc_hash::FxHashMap::default(), alias_editor_rows: Vec::new(), diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index b92a40783..d99cc66c1 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -650,8 +650,10 @@ impl super::OpenCADStudio { let history = solid_history::pyramid_op( glam::DMat4::IDENTITY.to_cols_array(), radius, + 0.0, height, n, + true, ); let handle = self.add_solid_model(entity, solid, history); self.tabs[i].scene.deselect_all(); diff --git a/src/app/properties.rs b/src/app/properties.rs index 6520a0939..d0038380e 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2074,6 +2074,9 @@ impl OpenCADStudio { }); } } + if specialized_primitive { + retain_specialized_primitive_sections(&mut sections); + } let title = match entity { acadrust::EntityType::Insert(ins) => { let is_xref = self.tabs[i] @@ -3047,6 +3050,30 @@ fn aggregate_solid_history_sections( merged } +fn retain_specialized_primitive_sections( + sections: &mut Vec, +) { + sections.iter_mut().for_each(|section| { + section.props.retain(|property| { + matches!( + property.field, + "color" + | "layer" + | "linetype" + | "linetype_scale" + | "plot_style" + | "lineweight" + | "transparency" + | "hyperlink" + | "material" + | "solid_history_type" + ) || crate::scene::model::solid_history::is_primitive_property(property.field) + || crate::scene::model::solid_history::is_history_choice(property.field) + }); + }); + sections.retain(|section| !section.props.is_empty()); +} + fn merge_sections( left: &[crate::scene::model::object::PropSection], right: &[crate::scene::model::object::PropSection], diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index 6c888fa40..f3116f38c 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -141,7 +141,113 @@ fn is_named_key(key: &str) -> bool { }) } +/// Input actions `run_shortcut` resolves directly instead of routing through +/// the command dispatcher — valid shortcut commands that never appear in the +/// command registry. Used to validate the shortcut editor's command column. +pub(super) const INPUT_ACTIONS: &[&str] = &[ + "FINALIZE", + "COMMANDSPACE", + "CANCEL", + "DELETESELECTED", + "BACKSPACE", + "DYNTAB", + "HISTORYPREV", + "HISTORYNEXT", + "CARETLEFT", + "CARETRIGHT", + "COMMANDHISTORY", + "TOGGLEOSNAP", + "OTRACK", + "DYNINPUT", + "SELECTALL", + "PASTECLIP", +]; + impl OpenCADStudio { + /// Commit the working rows to the live bindings, discarding an + /// unfinished draft and reporting duplicates. Shared by Apply and + /// Apply-and-Exit. + pub(super) fn finish_shortcut_editor(&mut self) { + // An add must end in a complete shortcut or a cancellation; + // applying with a half-filled draft discards the draft. + if self.shortcut_pending_add { + self.shortcut_editor_rows.remove(0); + self.shortcut_pending_add = false; + self.command_line + .push_error(crate::tf!("Incomplete shortcut row discarded.").as_ref()); + } + self.shortcut_capture_row = None; + // Rows normalizing to the same key silently overwrite each other in + // the binding map — surface the conflict instead. + let mut seen = rustc_hash::FxHashSet::default(); + let mut duplicates = Vec::new(); + for (key, _) in &self.shortcut_editor_rows { + let key = normalize_key(key); + if key.is_empty() { + continue; + } + if !seen.insert(key.clone()) { + duplicates.push(key); + } + } + if !duplicates.is_empty() { + self.command_line.push_error( + crate::tf!( + "Duplicate shortcut(s) ignored on Apply: {}", + duplicates.join(", ") + ) + .as_ref(), + ); + } + self.apply_shortcut_editor_rows(); + self.command_line.push_info( + crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(), + ); + } + + /// Reset the working rows and live bindings to the shipped defaults. + pub(super) fn reset_shortcuts_to_defaults(&mut self) { + let mut rows: Vec<(String, String)> = default_bindings() + .into_iter() + .collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + self.shortcut_editor_rows = rows; + self.shortcut_pending_add = false; + self.shortcut_capture_row = None; + self.shortcut_reset_confirm = false; + self.apply_shortcut_editor_rows(); + } + + /// True when the working rows differ from the live bindings — the editor + /// has un-applied changes a close would discard. + pub(super) fn shortcut_editor_dirty(&self) -> bool { + let rows: FxHashMap = self + .shortcut_editor_rows + .iter() + .filter_map(|(key, action)| { + let key = normalize_key(key); + let action = action.trim().to_uppercase(); + (!key.is_empty() && !action.is_empty()).then_some((key, action)) + }) + .collect(); + rows != self.shortcut_bindings + } + + /// A pending add finishes successfully once its draft row (row 0) has + /// both a key and a command; it then becomes a regular row of the table. + /// Called after each edit while a draft is pending. + pub(super) fn finish_pending_add(&mut self) { + if !self.shortcut_pending_add { + return; + } + if let Some((key, command)) = self.shortcut_editor_rows.first() { + if !key.trim().is_empty() && !command.trim().is_empty() { + self.shortcut_pending_add = false; + self.shortcut_capture_row = None; + } + } + } + pub(super) fn apply_shortcut_editor_rows(&mut self) { let bindings: FxHashMap = self .shortcut_editor_rows diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 020fca1c4..9ca1cdb1c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -199,7 +199,13 @@ impl OpenCADStudio { // Closing (✕) discards edits made since the last Apply — matching the // style editors. Committing happens only through the Apply button. Some(Aliases) => self.alias_editor_rows.clear(), - Some(Shortcuts) => self.shortcut_editor_rows.clear(), + Some(Shortcuts) => { + self.shortcut_editor_rows.clear(); + self.shortcut_capture_row = None; + self.shortcut_pending_add = false; + self.shortcut_reset_confirm = false; + self.shortcut_close_confirm = false; + } Some(LayerStateEditor) => { self.layer_state_edit_draft = None; self.layer_state_edit_filter.clear(); @@ -5564,13 +5570,31 @@ impl OpenCADStudio { .collect(); rows.sort_by(|a, b| a.0.cmp(&b.0)); self.shortcut_editor_rows = rows; + self.shortcut_capture_row = None; + self.shortcut_pending_add = false; + self.shortcut_reset_confirm = false; + self.shortcut_close_confirm = false; self.active_modal = Some(super::ModalKind::Shortcuts); Task::none() } Message::ShortcutsPanelClose => { + if !self.shortcut_close_confirm && self.shortcut_editor_dirty() { + self.shortcut_close_confirm = true; + return Task::none(); + } + self.shortcut_close_confirm = false; self.close_active_modal(); Task::none() } + Message::ShortcutEditorCloseDiscard => { + self.shortcut_close_confirm = false; + self.close_active_modal(); + Task::none() + } + Message::ShortcutEditorCloseKeep => { + self.shortcut_close_confirm = false; + Task::none() + } Message::ShortcutEditorInput { idx, field, value } => { use crate::ui::window::shortcuts::ShortcutField; if let Some(row) = self.shortcut_editor_rows.get_mut(idx) { @@ -5579,21 +5603,96 @@ impl OpenCADStudio { ShortcutField::Command => row.1 = value.to_uppercase(), } } + // Typing never finishes the addition — only the draft row's + // check button does, so no half-visible "ghost" row appears. Task::none() } Message::ShortcutEditorAdd => { - self.shortcut_editor_rows - .push((String::new(), String::new())); + if self.shortcut_pending_add { + // One draft at a time: re-arm its Key cell instead. + self.shortcut_capture_row = Some(0); + } else { + // The draft row goes to the top of the list so it is + // visible without scrolling, with its Key cell armed. + self.shortcut_editor_rows + .insert(0, (String::new(), String::new())); + self.shortcut_pending_add = true; + self.shortcut_capture_row = Some(0); + } + Task::none() + } + Message::ShortcutCaptureStart(idx) => { + // Clicking the armed cell again cancels capture. + self.shortcut_capture_row = match self.shortcut_capture_row { + Some(armed) if armed == idx => None, + _ => Some(idx), + }; + Task::none() + } + Message::ShortcutCaptureKey(raw) => { + if let Some(idx) = self.shortcut_capture_row.take() { + let key = crate::app::shortcuts::normalize_key(&raw); + if !key.is_empty() && self.shortcut_editor_rows.get_mut(idx).is_some() { + self.shortcut_editor_rows[idx].0 = key; + } + } + Task::none() + } + Message::ShortcutCaptureClear => { + self.shortcut_capture_row = None; + Task::none() + } + Message::ShortcutCaptureCancel => { + // Esc backs out of capture and abandons an unfinished draft. + self.shortcut_capture_row = None; + if self.shortcut_pending_add { + self.shortcut_editor_rows.remove(0); + self.shortcut_pending_add = false; + } Task::none() } Message::ShortcutEditorRemove(idx) => { if idx < self.shortcut_editor_rows.len() { self.shortcut_editor_rows.remove(idx); } + if idx == 0 { + // Removing the draft row is the ✕ cancel path. + self.shortcut_pending_add = false; + } + // Armed indices shift when a row above them disappears. + self.shortcut_capture_row = match self.shortcut_capture_row { + Some(armed) if armed == idx => None, + Some(armed) if armed > idx => Some(armed - 1), + other => other, + }; Task::none() } Message::ShortcutEditorApply => { - self.apply_shortcut_editor_rows(); + self.finish_shortcut_editor(); + Task::none() + } + Message::ShortcutEditorApplyExit => { + self.finish_shortcut_editor(); + self.close_active_modal(); + Task::none() + } + Message::ShortcutEditorDraftAccept => { + // The check button: finish the addition (keep the row in the + // working table) without applying it. Only complete drafts + // can be accepted — the button is disabled otherwise. + self.finish_pending_add(); + Task::none() + } + Message::ShortcutEditorResetAsk => { + self.shortcut_reset_confirm = true; + Task::none() + } + Message::ShortcutEditorResetDeny => { + self.shortcut_reset_confirm = false; + Task::none() + } + Message::ShortcutEditorResetConfirm => { + self.reset_shortcuts_to_defaults(); self.command_line.push_info( crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(), ); @@ -5793,6 +5892,17 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } + // Closing the shortcut editor with un-applied rows needs an + // explicit discard; a second close attempt (or the overlay's + // Discard button) goes through. + if self.active_modal == Some(super::ModalKind::Shortcuts) + && !self.shortcut_close_confirm + && self.shortcut_editor_dirty() + { + self.shortcut_close_confirm = true; + return Task::none(); + } + self.shortcut_close_confirm = false; let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 86f997cbe..014b90a84 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -118,6 +118,36 @@ fn hatch_pattern_key_event( } } +fn shortcut_capture_key_event( + event: iced::Event, + _status: iced::event::Status, + _window: window::Id, +) -> Option { + let iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event else { + return None; + }; + // Ignore bare modifier presses so holding Ctrl+Shift before the final + // key doesn't end the capture early. + if let keyboard::Key::Named(named) = &key { + if matches!( + named, + keyboard::key::Named::Control + | keyboard::key::Named::Shift + | keyboard::key::Named::Alt + | keyboard::key::Named::Super + | keyboard::key::Named::Meta + ) { + return None; + } + } + // Esc backs out of capture (and abandons an unfinished draft row) — + // binding the ESCAPE key itself is still possible via SHORTCUTS SET. + if matches!(key, keyboard::Key::Named(keyboard::key::Named::Escape)) { + return Some(Message::ShortcutCaptureCancel); + } + shortcut_key_name(&key, modifiers).map(Message::ShortcutCaptureKey) +} + fn shortcut_key_name(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option { let key = match key { keyboard::Key::Character(value) if !value.is_empty() => value.to_uppercase(), @@ -2198,19 +2228,12 @@ impl OpenCADStudio { } else { Subscription::none() }; - iced::Subscription::batch([ - frames, - history_tick, - grip_dwell, - hover_dwell, - nav_settle, - thumbnail_capture, - caret_blink, - web_fonts, - autosave, - plugin_drain, - single_instance, - hatch_pattern_keys, + // While a shortcut-editor Key cell is armed for capture, a dedicated + // listener records the pressed combination and swallows every other + // key so captured shortcuts do not run in the drawing. + let keyboard_events = if self.shortcut_capture_row.is_some() { + event::listen_with(shortcut_capture_key_event) + } else { event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { @@ -2340,7 +2363,22 @@ impl OpenCADStudio { } _ => None, } - }), + }) + }; + iced::Subscription::batch([ + frames, + history_tick, + grip_dwell, + hover_dwell, + nav_settle, + thumbnail_capture, + caret_blink, + web_fonts, + autosave, + plugin_drain, + single_instance, + hatch_pattern_keys, + keyboard_events, ]) } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0fa5e27a7..444b5fe4a 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -78,6 +78,68 @@ impl OpenCADStudio { automatic_flow(ex, crate::ui::window::about::view_window) } super::super::ModalKind::Shortcuts => { + // Keys claimed by two rows — the cells turn red and a + // persistent warning names the command already using each + // key; the last row wins in the binding map on Apply. + let mut key_rows: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for row in &self.shortcut_editor_rows { + let key = crate::app::shortcuts::normalize_key(&row.0); + if !key.is_empty() { + key_rows.entry(key).or_default().push(row); + } + } + let duplicate_keys: Vec = key_rows + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(key, _)| key.clone()) + .collect(); + let duplicate_conflicts: Vec<(String, String)> = key_rows + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(key, rows)| { + // Name the established binding: the first row with + // this key that actually has a command. + let command = rows + .iter() + .find(|(_, command)| !command.is_empty()) + .map(|(_, command)| command.clone()) + .unwrap_or_default(); + (key.clone(), command) + }) + .collect(); + let duplicate_set: rustc_hash::FxHashSet = + duplicate_keys.into_iter().collect(); + // Commands the dispatcher can't run: not a registered + // command, plugin command, alias, or input action. + let valid: rustc_hash::FxHashSet = + crate::command::all_registered_command_names() + .into_iter() + .map(str::to_uppercase) + .chain( + self.command_line + .dynamic_commands + .iter() + .map(|cmd| cmd.to_uppercase()), + ) + .chain(self.command_aliases.keys().cloned()) + .chain( + crate::app::shortcuts::INPUT_ACTIONS + .iter() + .map(|action| action.to_string()), + ) + .collect(); + let unknown_commands: Vec = self + .shortcut_editor_rows + .iter() + .filter_map(|(_, command)| { + let command = command.trim(); + (!command.is_empty() && !valid.contains(command)) + .then(|| command.to_string()) + }) + .collect(); + let unknown_set: rustc_hash::FxHashSet = + unknown_commands.into_iter().collect(); sized_flow( ex, 720, @@ -85,6 +147,13 @@ impl OpenCADStudio { |flow| { crate::ui::window::shortcuts::view_window( &self.shortcut_editor_rows, + self.shortcut_capture_row, + self.shortcut_pending_add, + self.shortcut_reset_confirm, + &duplicate_set, + &duplicate_conflicts, + &unknown_set, + self.shortcut_close_confirm, flow, ) }, diff --git a/src/entities/insert.rs b/src/entities/insert.rs index 17fa1be69..37f7dcd7f 100644 --- a/src/entities/insert.rs +++ b/src/entities/insert.rs @@ -189,16 +189,19 @@ pub(crate) fn insert_attribute_entities( document: &acadrust::CadDocument, insert: &acadrust::entities::Insert, annotation_scale: f32, + scale_policy: crate::scene::BlockScalePolicy, ) -> Vec { let visibility = document.header.attribute_visibility; if visibility == 0 || insert.attributes.is_empty() { return Vec::new(); } - let block_scale = if insert - .common - .extended_data - .get_record("AcAnnotativeData") - .is_some() + let block_scale = if scale_policy + == crate::scene::BlockScalePolicy::FromInsert + && insert + .common + .extended_data + .get_record("AcAnnotativeData") + .is_some() { annotation_scale as f64 } else { @@ -253,59 +256,75 @@ pub(crate) fn append_insert_attribute_wires( pslt_factor: f32, anno_scale: f32, ) { - for attr_entity in insert_attribute_entities(document, ins, anno_scale) { - let attr = attr_entity.common(); - let (sub_color, sub_plen, sub_pat, sub_lw_px, sub_aci) = render::render_style_for_block_sub( + let attributes = insert_attribute_entities( + document, + ins, + anno_scale, + crate::scene::BlockScalePolicy::FromInsert, + ); + for offset in crate::scene::render_graph::array_offsets(ins) { + let delta = crate::scene::render_graph::insert_instance_translation_delta( document, - &attr_entity, - ins_color, - ins_pat_len, - ins_pat, - ins_lw_px, - ins_layer, + ins, + offset, + anno_scale, + crate::scene::BlockScalePolicy::FromInsert, ); - let sub_color = render::adapt_to_bg(sub_color, bg_color); - let sub_color = if is_xref && !sel { - block_cache::fade_toward_bg(sub_color, bg_color) - } else { - sub_color - }; - let attr_plottable = if render::is_effective_layer_zero(&attr.layer) { - ins_layer_plottable - } else { - document - .layers - .get(&attr.layer) - .map(|layer| layer.is_plottable) - .unwrap_or(true) - }; - let attr_plottable = ins_layer_plottable && attr_plottable; - let sub_aabb = crate::scene::entity_aabb(&attr_entity); - let mut attr_wires = tessellate::tessellate( - document, - insert_handle, - &attr_entity, - sel, - sub_color, - sub_plen * pslt_factor, - sub_pat.map(|v| v * pslt_factor), - sub_lw_px, - // Neutral: the attribute is already scaled as part of the block unit - // above, so the text path must not scale it a second time. - 1.0, - None, - None, - bg_color, - false, - ); - // Use the INSERT's handle so selection / picking groups attribute - // text with the parent insert instead of treating it as a stray text. - for w in &mut attr_wires { - w.name = insert_handle.value().to_string(); - w.aci = sub_aci; - w.aabb = sub_aabb; - w.plot_visible &= attr_plottable; + for source in &attributes { + let mut attr_entity = source.clone(); + if delta != Vector3::ZERO { + attr_entity.apply_transform(&Transform::from_translation(delta)); + } + let attr = attr_entity.common(); + let (sub_color, sub_plen, sub_pat, sub_lw_px, sub_aci) = + render::render_style_for_block_sub( + document, + &attr_entity, + ins_color, + ins_pat_len, + ins_pat, + ins_lw_px, + ins_layer, + ); + let sub_color = render::adapt_to_bg(sub_color, bg_color); + let sub_color = if is_xref && !sel { + block_cache::fade_toward_bg(sub_color, bg_color) + } else { + sub_color + }; + let attr_plottable = if render::is_effective_layer_zero(&attr.layer) { + ins_layer_plottable + } else { + document + .layers + .get(&attr.layer) + .map(|layer| layer.is_plottable) + .unwrap_or(true) + }; + let attr_plottable = ins_layer_plottable && attr_plottable; + let sub_aabb = crate::scene::entity_aabb(&attr_entity); + let mut attr_wires = tessellate::tessellate( + document, + insert_handle, + &attr_entity, + sel, + sub_color, + sub_plen * pslt_factor, + sub_pat.map(|v| v * pslt_factor), + sub_lw_px, + 1.0, + None, + None, + bg_color, + false, + ); + for w in &mut attr_wires { + w.name = insert_handle.value().to_string(); + w.aci = sub_aci; + w.aabb = sub_aabb; + w.plot_visible &= attr_plottable; + } + wires.extend(attr_wires); } - wires.extend(attr_wires); } } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index c3737e28d..c1c4bcc65 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2129,7 +2129,10 @@ impl MultiLeaderTess for MultiLeader { view_aabb, world_per_pixel, 1.0, - crate::scene::convert::tess::BlockObjectOptions::default(), + crate::scene::convert::tess::BlockObjectOptions { + scale_policy: block_use.scale_policy, + ..crate::scene::convert::tess::BlockObjectOptions::default() + }, ) .wires, ); diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 3e4fcdb24..3beecc06f 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -67,6 +67,61 @@ enum SphereStep { }, } +#[derive(Clone, Copy)] +enum TorusStep { + Center, + Radius(DVec3), + Diameter(DVec3), + TwoPointFirst, + TwoPointSecond(DVec3), + ThreePointFirst, + ThreePointSecond(DVec3), + ThreePointThird(DVec3, DVec3), + TtrFirst, + TtrSecond { + object: TangentObject, + hit: DVec3, + }, + TtrRadius { + first: TangentObject, + second: TangentObject, + first_hit: DVec3, + second_hit: DVec3, + }, + TubeRadius { + center: DVec3, + major_radius: f64, + }, + TubeDiameter { + center: DVec3, + major_radius: f64, + }, + TubeTwoPointFirst { + center: DVec3, + major_radius: f64, + }, + TubeTwoPointSecond { + center: DVec3, + major_radius: f64, + first: DVec3, + }, +} + +#[derive(Clone, Copy)] +struct TorusDefaults { + major_radius: f64, + minor_radius: f64, +} + +impl Default for TorusDefaults { + fn default() -> Self { + Self { + major_radius: 100.0, + minor_radius: 25.0, + } + } +} + #[derive(Clone, Copy)] enum ConeStep { BaseCenter, @@ -99,6 +154,48 @@ enum ConeStep { TopRadius, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PyramidType { + Circumscribed, + Inscribed, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PyramidStep { + BaseCenter, + Sides, + EdgeFirst, + EdgeSecond, + BaseRadius, + Height, + HeightAfterTopRadius, + HeightFirstPoint, + HeightSecondPoint, + AxisEndpoint, + TopRadius, +} + +#[derive(Clone, Copy)] +struct PyramidDefaults { + displayed_base_radius: f64, + displayed_top_radius: f64, + height: f64, + sides: usize, + kind: PyramidType, +} + +impl Default for PyramidDefaults { + fn default() -> Self { + Self { + displayed_base_radius: 1.0, + displayed_top_radius: 0.0, + height: 1.0, + sides: 4, + kind: PyramidType::Circumscribed, + } + } +} + #[derive(Clone, Copy)] struct ConeDefaults { base_x_radius: f64, @@ -138,6 +235,33 @@ fn remember_sphere_radius(radius: f64) { } } +fn torus_defaults_store() -> &'static Mutex { + static VALUE: OnceLock> = OnceLock::new(); + VALUE.get_or_init(|| Mutex::new(TorusDefaults::default())) +} + +fn torus_defaults() -> TorusDefaults { + torus_defaults_store() + .lock() + .map(|value| *value) + .unwrap_or_default() +} + +fn remember_torus_defaults(major_radius: f64, minor_radius: f64) { + if major_radius.is_finite() + && minor_radius.is_finite() + && major_radius > 1e-6 + && minor_radius > 1e-6 + { + if let Ok(mut value) = torus_defaults_store().lock() { + *value = TorusDefaults { + major_radius, + minor_radius, + }; + } + } +} + fn sphere_tangent_local(object: TangentObject, plane: WorkingPlane) -> TangentObject { match object { TangentObject::Line { p1, p2 } => TangentObject::Line { @@ -211,6 +335,29 @@ fn cone_defaults() -> &'static std::sync::Mutex { DEFAULTS.get_or_init(|| std::sync::Mutex::new(ConeDefaults::default())) } +fn pyramid_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| Mutex::new(PyramidDefaults::default())) +} + +fn pyramid_apothem_factor(sides: usize) -> f64 { + (std::f64::consts::PI / sides.max(3) as f64).cos() +} + +fn pyramid_display_to_circumradius(value: f64, kind: PyramidType, sides: usize) -> f64 { + match kind { + PyramidType::Circumscribed => value / pyramid_apothem_factor(sides), + PyramidType::Inscribed => value, + } +} + +fn pyramid_circumradius_to_display(value: f64, kind: PyramidType, sides: usize) -> f64 { + match kind { + PyramidType::Circumscribed => value * pyramid_apothem_factor(sides), + PyramidType::Inscribed => value, + } +} + impl Shape { fn from_id(id: &str) -> Option { Some(match id { @@ -265,12 +412,23 @@ pub struct PrimitiveCommand { box_height_anchor: Option, sphere_step: SphereStep, sphere_default_radius: f64, + torus_step: TorusStep, + torus_defaults: TorusDefaults, cone_step: ConeStep, cone_frame: Option, cone_base_x_radius: f64, cone_base_y_radius: f64, cone_top_radius: f64, cone_defaults: ConeDefaults, + pyramid_step: PyramidStep, + pyramid_frame: Option, + pyramid_edge_first: Option, + pyramid_height_first: Option, + pyramid_base_radius: f64, + pyramid_top_radius: f64, + pyramid_sides: usize, + pyramid_type: PyramidType, + pyramid_defaults: PyramidDefaults, plane: WorkingPlane, } @@ -280,6 +438,10 @@ impl PrimitiveCommand { .lock() .map(|value| *value) .unwrap_or_default(); + let pyramid_remembered = pyramid_defaults() + .lock() + .map(|value| *value) + .unwrap_or_default(); Self { shape: Shape::from_id(id).unwrap_or(Shape::Box), pts: Vec::new(), @@ -294,12 +456,31 @@ impl PrimitiveCommand { box_height_anchor: None, sphere_step: SphereStep::Center, sphere_default_radius: sphere_radius_default(), + torus_step: TorusStep::Center, + torus_defaults: torus_defaults(), cone_step: ConeStep::BaseCenter, cone_frame: None, cone_base_x_radius: remembered.base_x_radius, cone_base_y_radius: remembered.base_y_radius, cone_top_radius: remembered.top_radius, cone_defaults: remembered, + pyramid_step: PyramidStep::BaseCenter, + pyramid_frame: None, + pyramid_edge_first: None, + pyramid_height_first: None, + pyramid_base_radius: pyramid_display_to_circumradius( + pyramid_remembered.displayed_base_radius, + pyramid_remembered.kind, + pyramid_remembered.sides, + ), + pyramid_top_radius: pyramid_display_to_circumradius( + pyramid_remembered.displayed_top_radius, + pyramid_remembered.kind, + pyramid_remembered.sides, + ), + pyramid_sides: pyramid_remembered.sides, + pyramid_type: pyramid_remembered.kind, + pyramid_defaults: pyramid_remembered, plane: WorkingPlane::default(), } } @@ -344,6 +525,81 @@ impl PrimitiveCommand { ))) } + fn set_torus_major_radius(&mut self, center: DVec3, major_radius: f64) -> CmdResult { + if !center.is_finite() || !major_radius.is_finite() || major_radius <= 1e-6 { + return CmdResult::NeedPoint; + } + self.torus_step = TorusStep::TubeRadius { + center, + major_radius, + }; + CmdResult::NeedPoint + } + + fn commit_torus( + &mut self, + center: DVec3, + major_radius: f64, + minor_radius: f64, + ) -> CmdResult { + if !center.is_finite() + || !major_radius.is_finite() + || !minor_radius.is_finite() + || major_radius <= 1e-6 + || minor_radius <= 1e-6 + { + return CmdResult::NeedPoint; + } + self.pts = vec![ + center, + center + DVec3::X * major_radius, + center + DVec3::X * minor_radius, + ]; + self.torus_defaults = TorusDefaults { + major_radius, + minor_radius, + }; + remember_torus_defaults(major_radius, minor_radius); + self.commit(0.0) + } + + fn torus_ttr_result(&mut self, radius: f64) -> CmdResult { + let TorusStep::TtrRadius { + first, + second, + first_hit, + second_hit, + } = self.torus_step + else { + return CmdResult::NeedPoint; + }; + let hint = (first_hit + second_hit) * 0.5; + let Some(center) = closest_sphere_center( + &sphere_ttr_centers(first, second, radius), + hint, + ) else { + return CmdResult::NeedPoint; + }; + self.set_torus_major_radius(center, radius) + } + + fn torus_preview( + &self, + center: DVec3, + major_radius: f64, + minor_radius: f64, + ) -> Option { + if !center.is_finite() + || !major_radius.is_finite() + || !minor_radius.is_finite() + || major_radius <= 1e-6 + || minor_radius <= 1e-6 + { + return None; + } + Some(self.place_preview(torus_wire(center, major_radius, minor_radius))) + } + fn cone_frame_at(&self, center: DVec3) -> WorkingPlane { WorkingPlane { origin: center, @@ -955,6 +1211,503 @@ impl PrimitiveCommand { } } + fn pyramid_frame_at(&self, center: DVec3) -> WorkingPlane { + WorkingPlane { + origin: center, + x: self.plane.x, + y: self.plane.y, + z: self.plane.z, + } + } + + fn set_pyramid_type(&mut self, kind: PyramidType) { + let displayed_top = pyramid_circumradius_to_display( + self.pyramid_top_radius, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_type = kind; + self.pyramid_top_radius = + pyramid_display_to_circumradius(displayed_top, kind, self.pyramid_sides); + } + + fn pyramid_set_base_from_point(&mut self, point: DVec3) -> bool { + let Some(frame) = self.pyramid_frame else { + return false; + }; + let delta = point - frame.origin; + let planar = delta - frame.z * delta.dot(frame.z); + let displayed_radius = planar.length(); + let Some(direction) = planar.try_normalize() else { + return false; + }; + if !displayed_radius.is_finite() || displayed_radius < 1e-6 { + return false; + } + let x = match self.pyramid_type { + PyramidType::Inscribed => direction, + PyramidType::Circumscribed => { + let half = std::f64::consts::PI / self.pyramid_sides as f64; + direction * half.cos() - frame.z.cross(direction) * half.sin() + } + }; + let Some(x) = x.try_normalize() else { + return false; + }; + let Some(y) = frame.z.cross(x).try_normalize() else { + return false; + }; + self.pyramid_frame = Some(WorkingPlane::new(frame.origin, x, y)); + self.pyramid_base_radius = pyramid_display_to_circumradius( + displayed_radius, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_step = PyramidStep::Height; + true + } + + fn pyramid_set_base_from_edge(&mut self, first: DVec3, second: DVec3) -> bool { + let delta = second - first; + let planar = delta - self.plane.z * delta.dot(self.plane.z); + let length = planar.length(); + let Some(edge) = planar.try_normalize() else { + return false; + }; + if !length.is_finite() || length < 1e-6 { + return false; + } + let radius = length + / (2.0 * (std::f64::consts::PI / self.pyramid_sides as f64).sin()); + let apothem = radius * pyramid_apothem_factor(self.pyramid_sides); + let center = (first + second) * 0.5 + self.plane.z.cross(edge) * apothem; + let Some(x) = (first - center).try_normalize() else { + return false; + }; + let Some(y) = self.plane.z.cross(x).try_normalize() else { + return false; + }; + self.pyramid_frame = Some(WorkingPlane::new(center, x, y)); + self.pyramid_base_radius = radius; + self.pyramid_step = PyramidStep::Height; + true + } + + fn pyramid_axis_frame(&self, axis: DVec3) -> Option<(WorkingPlane, f64)> { + let current = self.pyramid_frame?; + let height = axis.length(); + if !height.is_finite() || height < 1e-6 { + return None; + } + let z = axis / height; + let mut x = current.x - z * current.x.dot(z); + if x.length_squared() < 1e-12 { + x = current.y - z * current.y.dot(z); + } + let x = x.try_normalize()?; + let y = z.cross(x).try_normalize()?; + Some((WorkingPlane::new(current.origin, x, y), height)) + } + + fn pyramid_history_transform(frame: WorkingPlane) -> [f64; 16] { + glam::DMat4::from_cols( + frame.x.extend(0.0), + frame.y.extend(0.0), + frame.z.extend(0.0), + frame.origin.extend(1.0), + ) + .to_cols_array() + } + + fn build_pyramid(&self, axis: DVec3) -> Option<(Body, SolidHistoryOperation, f64)> { + let (frame, height) = self.pyramid_axis_frame(axis)?; + let local = solid_model::pyramid_frustum_solid( + [0.0; 3], + self.pyramid_base_radius, + self.pyramid_top_radius, + height, + self.pyramid_sides, + )?; + let placed = solid_model::placed( + &local, + frame.x.to_array(), + frame.y.to_array(), + frame.z.to_array(), + frame.origin.to_array(), + )?; + Some(( + placed, + crate::scene::model::solid_history::pyramid_op( + Self::pyramid_history_transform(frame), + self.pyramid_base_radius, + self.pyramid_top_radius, + height, + self.pyramid_sides, + self.pyramid_type == PyramidType::Inscribed, + ), + height, + )) + } + + fn commit_pyramid_axis(&self, axis: DVec3) -> CmdResult { + let Some((solid, history, height)) = self.build_pyramid(axis) else { + return CmdResult::NeedPoint; + }; + if let Ok(mut defaults) = pyramid_defaults().lock() { + *defaults = PyramidDefaults { + displayed_base_radius: pyramid_circumradius_to_display( + self.pyramid_base_radius, + self.pyramid_type, + self.pyramid_sides, + ), + displayed_top_radius: pyramid_circumradius_to_display( + self.pyramid_top_radius, + self.pyramid_type, + self.pyramid_sides, + ), + height, + sides: self.pyramid_sides, + kind: self.pyramid_type, + }; + } + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid) else { + return CmdResult::Cancel; + }; + let mut entity = Solid3D::new(); + entity.set_sat_document(&document); + entity.wires = solid_model::edge_wires(&solid); + CmdResult::CommitSolid { + entity: EntityType::Solid3D(entity), + solid: Box::new(solid), + history, + erase_source: None, + } + } + + fn commit_pyramid_height(&self, height: f64) -> CmdResult { + let Some(frame) = self.pyramid_frame else { + return CmdResult::NeedPoint; + }; + if !height.is_finite() || height.abs() < 1e-6 { + return CmdResult::NeedPoint; + } + self.commit_pyramid_axis(frame.z * height) + } + + fn pyramid_height_at(&self, point: DVec3) -> Option { + let frame = self.pyramid_frame?; + Some((point - frame.origin).dot(frame.z)) + } + + fn pyramid_polygon(frame: WorkingPlane, radius: f64, sides: usize, z: f64) -> Vec { + (0..sides) + .map(|index| { + let angle = std::f64::consts::TAU * index as f64 / sides as f64; + frame.origin + + frame.x * (radius * angle.cos()) + + frame.y * (radius * angle.sin()) + + frame.z * z + }) + .collect() + } + + fn pyramid_preview(&self, axis: DVec3) -> Option { + let (frame, height) = self.pyramid_axis_frame(axis)?; + let base = Self::pyramid_polygon(frame, self.pyramid_base_radius, self.pyramid_sides, 0.0); + let top = Self::pyramid_polygon(frame, self.pyramid_top_radius, self.pyramid_sides, height); + let apex = frame.origin + frame.z * height; + let mut points = Vec::new(); + push_path_loop(&mut points, &base); + if self.pyramid_top_radius > 1e-9 { + push_path_loop(&mut points, &top); + } + for index in 0..self.pyramid_sides { + push_segment( + &mut points, + base[index], + if self.pyramid_top_radius > 1e-9 { top[index] } else { apex }, + ); + } + Some(wire("primitive_height_preview", points)) + } + + fn pyramid_base_preview(&self, frame: WorkingPlane, displayed_radius: f64) -> Option { + if !displayed_radius.is_finite() || displayed_radius < 1e-9 { + return None; + } + let radius = pyramid_display_to_circumradius( + displayed_radius, + self.pyramid_type, + self.pyramid_sides, + ); + let points = Self::pyramid_polygon(frame, radius, self.pyramid_sides, 0.0); + let mut wire_points = Vec::new(); + push_path_loop(&mut wire_points, &points); + Some(wire("primitive_preview", wire_points)) + } + + fn pyramid_prompt(&self) -> String { + let base_default = self.pyramid_defaults.displayed_base_radius; + let top_default = self.pyramid_defaults.displayed_top_radius; + match self.pyramid_step { + PyramidStep::BaseCenter => crate::tf!( + "PYRAMID Specify center point of base or [Edge/Sides]:" + ).into_owned(), + PyramidStep::Sides => crate::tf!( + "PYRAMID Enter number of sides <{}>:", + self.pyramid_sides + ).into_owned(), + PyramidStep::EdgeFirst => t!("PYRAMID Specify first endpoint of edge:").into_owned(), + PyramidStep::EdgeSecond => t!("PYRAMID Specify second endpoint of edge:").into_owned(), + PyramidStep::BaseRadius => match self.pyramid_type { + PyramidType::Circumscribed => crate::tf!( + "PYRAMID Specify base radius or [Inscribed] <{:.4}>:", + base_default + ).into_owned(), + PyramidType::Inscribed => crate::tf!( + "PYRAMID Specify base radius or [Circumscribed] <{:.4}>:", + base_default + ).into_owned(), + }, + PyramidStep::Height => crate::tf!( + "PYRAMID Specify height or [2Point/Axis endpoint/Top radius] <{:.4}>:", + self.pyramid_defaults.height + ).into_owned(), + PyramidStep::HeightAfterTopRadius => crate::tf!( + "PYRAMID Specify height or [2Point/Axis endpoint] <{:.4}>:", + self.pyramid_defaults.height + ).into_owned(), + PyramidStep::HeightFirstPoint => t!("PYRAMID Specify first point for height:").into_owned(), + PyramidStep::HeightSecondPoint => t!("PYRAMID Specify second point for height:").into_owned(), + PyramidStep::AxisEndpoint => t!("PYRAMID Specify axis endpoint:").into_owned(), + PyramidStep::TopRadius => crate::tf!( + "PYRAMID Specify top radius <{:.4}>:", + top_default + ).into_owned(), + } + } + + fn pyramid_options(&self) -> Vec { + match self.pyramid_step { + PyramidStep::BaseCenter => vec![ + CmdOption::new(t!("Edge").as_ref(), "E"), + CmdOption::new(t!("Sides").as_ref(), "S"), + ], + PyramidStep::BaseRadius => vec![match self.pyramid_type { + PyramidType::Circumscribed => CmdOption::new(t!("Inscribed").as_ref(), "I"), + PyramidType::Inscribed => CmdOption::new(t!("Circumscribed").as_ref(), "C"), + }], + PyramidStep::Height => vec![ + CmdOption::new("2Point", "2P"), + CmdOption::new(t!("Axis endpoint").as_ref(), "A"), + CmdOption::new(t!("Top radius").as_ref(), "T"), + ], + PyramidStep::HeightAfterTopRadius => vec![ + CmdOption::new("2Point", "2P"), + CmdOption::new(t!("Axis endpoint").as_ref(), "A"), + ], + _ => Vec::new(), + } + } + + fn on_pyramid_point(&mut self, point: DVec3) -> CmdResult { + match self.pyramid_step { + PyramidStep::BaseCenter => { + self.pyramid_frame = Some(self.pyramid_frame_at(point)); + self.pyramid_step = PyramidStep::BaseRadius; + CmdResult::NeedPoint + } + PyramidStep::EdgeFirst => { + self.pyramid_edge_first = Some(point); + self.pyramid_step = PyramidStep::EdgeSecond; + CmdResult::NeedPoint + } + PyramidStep::EdgeSecond => { + if let Some(first) = self.pyramid_edge_first { + self.pyramid_set_base_from_edge(first, point); + } + CmdResult::NeedPoint + } + PyramidStep::BaseRadius => { + self.pyramid_set_base_from_point(point); + CmdResult::NeedPoint + } + PyramidStep::Height | PyramidStep::HeightAfterTopRadius => self + .pyramid_height_at(point) + .map(|height| self.commit_pyramid_height(height)) + .unwrap_or(CmdResult::NeedPoint), + PyramidStep::HeightFirstPoint => { + self.pyramid_height_first = Some(point); + self.pyramid_step = PyramidStep::HeightSecondPoint; + CmdResult::NeedPoint + } + PyramidStep::HeightSecondPoint => self + .pyramid_height_first + .map(|first| self.commit_pyramid_height(first.distance(point))) + .unwrap_or(CmdResult::NeedPoint), + PyramidStep::AxisEndpoint => { + let Some(frame) = self.pyramid_frame else { + return CmdResult::NeedPoint; + }; + self.commit_pyramid_axis(point - frame.origin) + } + PyramidStep::TopRadius => { + let Some(frame) = self.pyramid_frame else { + return CmdResult::NeedPoint; + }; + let delta = point - frame.origin; + let displayed = (delta - frame.z * delta.dot(frame.z)).length(); + if displayed.is_finite() { + self.pyramid_top_radius = pyramid_display_to_circumradius( + displayed, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_step = PyramidStep::HeightAfterTopRadius; + } + CmdResult::NeedPoint + } + PyramidStep::Sides => CmdResult::NeedPoint, + } + } + + fn on_pyramid_text(&mut self, raw: &str) -> Option { + let token = raw.trim(); + let upper = token.to_uppercase(); + match self.pyramid_step { + PyramidStep::BaseCenter => { + self.pyramid_step = match upper.as_str() { + "E" | "EDGE" => PyramidStep::EdgeFirst, + "S" | "SIDES" => PyramidStep::Sides, + _ => return None, + }; + return Some(CmdResult::NeedPoint); + } + PyramidStep::BaseRadius if matches!(upper.as_str(), "I" | "INSCRIBED") => { + self.set_pyramid_type(PyramidType::Inscribed); + return Some(CmdResult::NeedPoint); + } + PyramidStep::BaseRadius if matches!(upper.as_str(), "C" | "CIRCUMSCRIBED") => { + self.set_pyramid_type(PyramidType::Circumscribed); + return Some(CmdResult::NeedPoint); + } + PyramidStep::Height => { + self.pyramid_step = match upper.as_str() { + "2P" | "2POINT" => PyramidStep::HeightFirstPoint, + "A" | "AXIS" | "AXIS ENDPOINT" => PyramidStep::AxisEndpoint, + "T" | "TOP" | "TOP RADIUS" => PyramidStep::TopRadius, + _ => { + let height = crate::entities::common::parse_typed_length(token)?; + return Some(self.commit_pyramid_height(height)); + } + }; + return Some(CmdResult::NeedPoint); + } + PyramidStep::HeightAfterTopRadius => { + self.pyramid_step = match upper.as_str() { + "2P" | "2POINT" => PyramidStep::HeightFirstPoint, + "A" | "AXIS" | "AXIS ENDPOINT" => PyramidStep::AxisEndpoint, + _ => { + let height = crate::entities::common::parse_typed_length(token)?; + return Some(self.commit_pyramid_height(height)); + } + }; + return Some(CmdResult::NeedPoint); + } + PyramidStep::Sides => { + let sides = token.parse::().ok()?; + if !(3..=32).contains(&sides) { + return None; + } + let displayed_top = pyramid_circumradius_to_display( + self.pyramid_top_radius, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_sides = sides; + self.pyramid_top_radius = pyramid_display_to_circumradius( + displayed_top, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_defaults.sides = sides; + self.pyramid_step = PyramidStep::BaseCenter; + return Some(CmdResult::NeedPoint); + } + _ => {} + } + let number = crate::entities::common::parse_typed_length(token)?; + match self.pyramid_step { + PyramidStep::BaseRadius if number > 0.0 => { + let frame = self.pyramid_frame?; + self.pyramid_set_base_from_point(frame.origin + frame.x * number) + .then_some(CmdResult::NeedPoint) + } + PyramidStep::TopRadius if number >= 0.0 => { + self.pyramid_top_radius = pyramid_display_to_circumradius( + number, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_step = PyramidStep::HeightAfterTopRadius; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn pyramid_mouse_move(&self, point: DVec3) -> Option { + match self.pyramid_step { + PyramidStep::EdgeSecond => { + let first = self.pyramid_edge_first?; + let mut preview = PrimitiveCommand::new("PYRAMID"); + preview.plane = self.plane; + preview.pyramid_sides = self.pyramid_sides; + preview.pyramid_type = self.pyramid_type; + preview.pyramid_set_base_from_edge(first, point); + let frame = preview.pyramid_frame?; + preview.pyramid_base_preview( + frame, + pyramid_circumradius_to_display( + preview.pyramid_base_radius, + preview.pyramid_type, + preview.pyramid_sides, + ), + ) + } + PyramidStep::BaseRadius => { + let frame = self.pyramid_frame?; + let delta = point - frame.origin; + let planar = delta - frame.z * delta.dot(frame.z); + let displayed = planar.length(); + let direction = planar.try_normalize()?; + let x = match self.pyramid_type { + PyramidType::Inscribed => direction, + PyramidType::Circumscribed => { + let half = std::f64::consts::PI / self.pyramid_sides as f64; + direction * half.cos() - frame.z.cross(direction) * half.sin() + } + } + .try_normalize()?; + let y = frame.z.cross(x).try_normalize()?; + self.pyramid_base_preview(WorkingPlane::new(frame.origin, x, y), displayed) + } + PyramidStep::Height | PyramidStep::HeightAfterTopRadius => { + let frame = self.pyramid_frame?; + self.pyramid_preview(frame.z * self.pyramid_height_at(point)?) + } + PyramidStep::HeightSecondPoint => { + let frame = self.pyramid_frame?; + self.pyramid_preview(frame.z * self.pyramid_height_first?.distance(point)) + } + PyramidStep::AxisEndpoint => { + let frame = self.pyramid_frame?; + self.pyramid_preview(point - frame.origin) + } + _ => None, + } + } + /// Number of footprint points the shape needs before the height step. fn footprint_pts(&self) -> usize { match self.shape { @@ -1274,28 +2027,14 @@ impl PrimitiveCommand { solid_history::sphere_op(self.history_transform(c), r), ) } - Shape::Pyramid => { - let c = self.pts[0]; - let r = (self.pts[1] - c).length(); - if r < 1e-6 || height < 1e-6 { - return None; - } - ( - solid_model::pyramid_solid([c.x, c.y, c.z], r, height, 4), - solid_history::pyramid_op(self.history_transform(c), r, height, 4), - ) - } + Shape::Pyramid => return None, Shape::Torus => { let c = self.pts[0]; - let first = (self.pts[1] - c).length(); - let second = (self.pts[2] - c).length(); - let outer = first.max(second); - let inner = first.min(second); - if inner < 1e-6 || outer - inner < 1e-6 { + let major = (self.pts[1] - c).length(); + let minor = (self.pts[2] - c).length(); + if major < 1e-6 || minor < 1e-6 { return None; } - let major = (outer + inner) * 0.5; - let minor = (outer - inner) * 0.5; let center = [c.x, c.y, c.z]; ( solid_model::torus_solid(center, major, minor), @@ -1367,6 +2106,17 @@ impl CadCommand for PrimitiveCommand { (frame.origin, frame.z.normalize_or_zero()) }); } + + if self.shape == Shape::Pyramid { + return matches!( + self.pyramid_step, + PyramidStep::Height | PyramidStep::HeightAfterTopRadius + ) + .then(|| { + let frame = self.pyramid_frame.unwrap_or(self.plane); + (frame.origin, frame.z.normalize_or_zero()) + }); + } let constrained = if self.shape.rectangular() { self.box_step == BoxStep::Height } else { @@ -1395,12 +2145,40 @@ impl CadCommand for PrimitiveCommand { self.cone_step, ConeStep::TtrFirst | ConeStep::TtrSecond { .. } )) + || (self.shape == Shape::Torus + && matches!( + self.torus_step, + TorusStep::TtrFirst | TorusStep::TtrSecond { .. } + )) } fn on_tangent_point(&mut self, object: TangentObject, hit: DVec3) -> CmdResult { if self.shape == Shape::Cone { return self.on_cone_tangent(object, hit); } + if self.shape == Shape::Torus { + let object = sphere_tangent_local(object, self.plane); + let hit = self.plane.to_local(hit); + return match self.torus_step { + TorusStep::TtrFirst => { + self.torus_step = TorusStep::TtrSecond { object, hit }; + CmdResult::NeedPoint + } + TorusStep::TtrSecond { + object: first, + hit: first_hit, + } => { + self.torus_step = TorusStep::TtrRadius { + first, + second: object, + first_hit, + second_hit: hit, + }; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + }; + } if self.shape != Shape::Sphere { return self.on_point(hit); } @@ -1432,6 +2210,9 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Cone { return self.cone_prompt(); } + if self.shape == Shape::Pyramid { + return self.pyramid_prompt(); + } if self.shape.rectangular() { return match self.box_step { BoxStep::FirstCorner => t!("%{n} Specify first corner or [Center]:", n = n), @@ -1500,13 +2281,69 @@ impl CadCommand for PrimitiveCommand { .into_owned(), }; } + if self.shape == Shape::Torus { + return match self.torus_step { + TorusStep::Center => { + t!("TORUS Specify center point or [3P/2P/Ttr]:").into_owned() + } + TorusStep::Radius(_) => crate::tf!( + "TORUS Specify radius or [Diameter] <{:.4}>:", + self.torus_defaults.major_radius + ) + .into_owned(), + TorusStep::Diameter(_) => crate::tf!( + "TORUS Specify diameter <{:.4}>:", + self.torus_defaults.major_radius * 2.0 + ) + .into_owned(), + TorusStep::TwoPointFirst => { + t!("TORUS Specify first end point of diameter:").into_owned() + } + TorusStep::TwoPointSecond(_) => { + t!("TORUS Specify second end point of diameter:").into_owned() + } + TorusStep::ThreePointFirst => { + t!("TORUS Specify first point on torus:").into_owned() + } + TorusStep::ThreePointSecond(_) => { + t!("TORUS Specify second point on torus:").into_owned() + } + TorusStep::ThreePointThird(_, _) => { + t!("TORUS Specify third point on torus:").into_owned() + } + TorusStep::TtrFirst => { + t!("TORUS Select first tangent object:").into_owned() + } + TorusStep::TtrSecond { .. } => { + t!("TORUS Select second tangent object:").into_owned() + } + TorusStep::TtrRadius { .. } => crate::tf!( + "TORUS Specify radius <{:.4}>:", + self.torus_defaults.major_radius + ) + .into_owned(), + TorusStep::TubeRadius { .. } => crate::tf!( + "TORUS Specify tube radius or [2Point/Diameter] <{:.4}>:", + self.torus_defaults.minor_radius + ) + .into_owned(), + TorusStep::TubeDiameter { .. } => crate::tf!( + "TORUS Specify tube diameter <{:.4}>:", + self.torus_defaults.minor_radius * 2.0 + ) + .into_owned(), + TorusStep::TubeTwoPointFirst { .. } => { + t!("TORUS Specify first end point of tube diameter:").into_owned() + } + TorusStep::TubeTwoPointSecond { .. } => { + t!("TORUS Specify second end point of tube diameter:").into_owned() + } + }; + } if self.height_step { return t!("%{n} Specify height :", n = n).into_owned(); } match (self.shape, self.pts.len()) { - (Shape::Torus, 0) => t!("%{n} Specify center point:", n = n).into_owned(), - (Shape::Torus, 1) => t!("%{n} Specify outer radius:", n = n).into_owned(), - (Shape::Torus, _) => t!("%{n} Specify inner radius:", n = n).into_owned(), (shape, 0) if shape.radial() => { t!("%{n} Specify center point:", n = n).into_owned() } @@ -1530,9 +2367,29 @@ impl CadCommand for PrimitiveCommand { _ => Vec::new(), }; } + if self.shape == Shape::Torus { + return match self.torus_step { + TorusStep::Center => vec![ + CmdOption::new("3P", "3P"), + CmdOption::new("2P", "2P"), + CmdOption::new("Ttr", "T"), + ], + TorusStep::Radius(_) => { + vec![CmdOption::new(t!("Diameter").as_ref(), "D")] + } + TorusStep::TubeRadius { .. } => vec![ + CmdOption::new(t!("2Point").as_ref(), "2P"), + CmdOption::new(t!("Diameter").as_ref(), "D"), + ], + _ => Vec::new(), + }; + } if self.shape == Shape::Cone { return self.cone_options(); } + if self.shape == Shape::Pyramid { + return self.pyramid_options(); + } if !self.shape.rectangular() { return Vec::new(); } @@ -1591,9 +2448,82 @@ impl CadCommand for PrimitiveCommand { SphereStep::TtrFirst | SphereStep::TtrSecond { .. } => CmdResult::NeedPoint, }; } + if self.shape == Shape::Torus { + let local = self.plane.to_local(pt); + if !local.is_finite() { + return CmdResult::NeedPoint; + } + return match self.torus_step { + TorusStep::Center => { + self.torus_step = TorusStep::Radius(local); + CmdResult::NeedPoint + } + TorusStep::Radius(center) => { + self.set_torus_major_radius(center, center.distance(local)) + } + TorusStep::Diameter(center) => { + self.set_torus_major_radius(center, center.distance(local) * 0.5) + } + TorusStep::TwoPointFirst => { + self.torus_step = TorusStep::TwoPointSecond(local); + CmdResult::NeedPoint + } + TorusStep::TwoPointSecond(first) => self.set_torus_major_radius( + (first + local) * 0.5, + first.distance(local) * 0.5, + ), + TorusStep::ThreePointFirst => { + self.torus_step = TorusStep::ThreePointSecond(local); + CmdResult::NeedPoint + } + TorusStep::ThreePointSecond(first) => { + self.torus_step = TorusStep::ThreePointThird(first, local); + CmdResult::NeedPoint + } + TorusStep::ThreePointThird(first, second) => { + let Some((center, radius)) = + sphere_through_three_points(first, second, local) + else { + return CmdResult::NeedPoint; + }; + self.set_torus_major_radius(center, radius) + } + TorusStep::TtrRadius { second_hit, .. } => { + self.torus_ttr_result(second_hit.distance(local)) + } + TorusStep::TubeRadius { + center, + major_radius, + } => self.commit_torus(center, major_radius, center.distance(local)), + TorusStep::TubeDiameter { + center, + major_radius, + } => self.commit_torus(center, major_radius, center.distance(local) * 0.5), + TorusStep::TubeTwoPointFirst { + center, + major_radius, + } => { + self.torus_step = TorusStep::TubeTwoPointSecond { + center, + major_radius, + first: local, + }; + CmdResult::NeedPoint + } + TorusStep::TubeTwoPointSecond { + center, + major_radius, + first, + } => self.commit_torus(center, major_radius, first.distance(local) * 0.5), + TorusStep::TtrFirst | TorusStep::TtrSecond { .. } => CmdResult::NeedPoint, + }; + } if self.shape == Shape::Cone { return self.on_cone_point(pt); } + if self.shape == Shape::Pyramid { + return self.on_pyramid_point(pt); + } if self.shape.rectangular() { let local = self.plane.to_local(pt); if !local.is_finite() { @@ -1698,6 +2628,25 @@ impl CadCommand for PrimitiveCommand { _ => CmdResult::Cancel, }; } + if self.shape == Shape::Torus { + return match self.torus_step { + TorusStep::Radius(center) | TorusStep::Diameter(center) => { + self.set_torus_major_radius(center, self.torus_defaults.major_radius) + } + TorusStep::TtrRadius { .. } => { + self.torus_ttr_result(self.torus_defaults.major_radius) + } + TorusStep::TubeRadius { + center, + major_radius, + } + | TorusStep::TubeDiameter { + center, + major_radius, + } => self.commit_torus(center, major_radius, self.torus_defaults.minor_radius), + _ => CmdResult::Cancel, + }; + } if self.shape == Shape::Cone { return match self.cone_step { ConeStep::BaseRadius | ConeStep::BaseDiameter => { @@ -1737,6 +2686,35 @@ impl CadCommand for PrimitiveCommand { _ => CmdResult::Cancel, }; } + if self.shape == Shape::Pyramid { + return match self.pyramid_step { + PyramidStep::Sides => { + self.pyramid_step = PyramidStep::BaseCenter; + CmdResult::NeedPoint + } + PyramidStep::BaseRadius => { + let displayed = self.pyramid_defaults.displayed_base_radius; + let Some(frame) = self.pyramid_frame else { + return CmdResult::Cancel; + }; + self.pyramid_set_base_from_point(frame.origin + frame.x * displayed); + CmdResult::NeedPoint + } + PyramidStep::Height | PyramidStep::HeightAfterTopRadius => { + self.commit_pyramid_height(self.pyramid_defaults.height) + } + PyramidStep::TopRadius => { + self.pyramid_top_radius = pyramid_display_to_circumradius( + self.pyramid_defaults.displayed_top_radius, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_step = PyramidStep::HeightAfterTopRadius; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + }; + } if self.shape.rectangular() { return if self.shape == Shape::Wedge && self.box_step == BoxStep::Height { self.commit(self.default_height()) @@ -1765,6 +2743,17 @@ impl CadCommand for PrimitiveCommand { | SphereStep::TtrRadius { .. } ); } + if self.shape == Shape::Torus { + return matches!( + self.torus_step, + TorusStep::Center + | TorusStep::Radius(_) + | TorusStep::Diameter(_) + | TorusStep::TtrRadius { .. } + | TorusStep::TubeRadius { .. } + | TorusStep::TubeDiameter { .. } + ); + } if self.shape == Shape::Cone { return matches!( self.cone_step, @@ -1779,6 +2768,17 @@ impl CadCommand for PrimitiveCommand { | ConeStep::TopRadius ); } + if self.shape == Shape::Pyramid { + return matches!( + self.pyramid_step, + PyramidStep::BaseCenter + | PyramidStep::Sides + | PyramidStep::BaseRadius + | PyramidStep::Height + | PyramidStep::HeightAfterTopRadius + | PyramidStep::TopRadius + ); + } if self.shape.rectangular() { matches!( self.box_step, @@ -1798,6 +2798,12 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Sphere { return matches!(self.sphere_step, SphereStep::Center | SphereStep::Radius(_)); } + if self.shape == Shape::Torus { + return matches!( + self.torus_step, + TorusStep::Center | TorusStep::Radius(_) | TorusStep::TubeRadius { .. } + ); + } if self.shape == Shape::Cone { return matches!( self.cone_step, @@ -1808,6 +2814,15 @@ impl CadCommand for PrimitiveCommand { | ConeStep::HeightAfterTopRadius ); } + if self.shape == Shape::Pyramid { + return matches!( + self.pyramid_step, + PyramidStep::BaseCenter + | PyramidStep::BaseRadius + | PyramidStep::Height + | PyramidStep::HeightAfterTopRadius + ); + } self.shape.rectangular() && matches!( self.box_step, @@ -1854,9 +2869,81 @@ impl CadCommand for PrimitiveCommand { _ => None, }; } + if self.shape == Shape::Torus { + let token = raw.trim(); + let upper = token.to_uppercase(); + return match self.torus_step { + TorusStep::Center if upper == "3P" => { + self.torus_step = TorusStep::ThreePointFirst; + Some(CmdResult::NeedPoint) + } + TorusStep::Center if upper == "2P" => { + self.torus_step = TorusStep::TwoPointFirst; + Some(CmdResult::NeedPoint) + } + TorusStep::Center if matches!(upper.as_str(), "T" | "TTR") => { + self.torus_step = TorusStep::TtrFirst; + Some(CmdResult::NeedPoint) + } + TorusStep::Radius(center) if matches!(upper.as_str(), "D" | "DIAMETER") => { + self.torus_step = TorusStep::Diameter(center); + Some(CmdResult::NeedPoint) + } + TorusStep::Radius(center) => { + let radius = crate::entities::common::parse_typed_length(token)?; + Some(self.set_torus_major_radius(center, radius)) + } + TorusStep::Diameter(center) => { + let diameter = crate::entities::common::parse_typed_length(token)?; + Some(self.set_torus_major_radius(center, diameter * 0.5)) + } + TorusStep::TtrRadius { .. } => { + let radius = crate::entities::common::parse_typed_length(token)?; + Some(self.torus_ttr_result(radius)) + } + TorusStep::TubeRadius { + center, + major_radius, + } if matches!(upper.as_str(), "2P" | "2POINT") => { + self.torus_step = TorusStep::TubeTwoPointFirst { + center, + major_radius, + }; + Some(CmdResult::NeedPoint) + } + TorusStep::TubeRadius { + center, + major_radius, + } if matches!(upper.as_str(), "D" | "DIAMETER") => { + self.torus_step = TorusStep::TubeDiameter { + center, + major_radius, + }; + Some(CmdResult::NeedPoint) + } + TorusStep::TubeRadius { + center, + major_radius, + } => { + let radius = crate::entities::common::parse_typed_length(token)?; + Some(self.commit_torus(center, major_radius, radius)) + } + TorusStep::TubeDiameter { + center, + major_radius, + } => { + let diameter = crate::entities::common::parse_typed_length(token)?; + Some(self.commit_torus(center, major_radius, diameter * 0.5)) + } + _ => None, + }; + } if self.shape == Shape::Cone { return self.on_cone_text(raw); } + if self.shape == Shape::Pyramid { + return self.on_pyramid_text(raw); + } if self.shape.rectangular() { let token = raw.trim(); let upper = token.to_uppercase(); @@ -1905,6 +2992,63 @@ impl CadCommand for PrimitiveCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { + if self.shape == Shape::Torus { + let local = self.plane.to_local(pt); + if !local.is_finite() { + return None; + } + return match self.torus_step { + TorusStep::Radius(center) => self.torus_preview( + center, + center.distance(local), + self.torus_defaults.minor_radius, + ), + TorusStep::Diameter(center) => self.torus_preview( + center, + center.distance(local) * 0.5, + self.torus_defaults.minor_radius, + ), + TorusStep::TwoPointSecond(first) | TorusStep::ThreePointSecond(first) => { + self.torus_preview( + (first + local) * 0.5, + first.distance(local) * 0.5, + self.torus_defaults.minor_radius, + ) + } + TorusStep::ThreePointThird(first, second) => { + let (center, radius) = sphere_through_three_points(first, second, local)?; + self.torus_preview(center, radius, self.torus_defaults.minor_radius) + } + TorusStep::TtrRadius { + first, + second, + first_hit, + second_hit, + } => { + let radius = second_hit.distance(local); + let hint = (first_hit + second_hit) * 0.5; + let center = closest_sphere_center( + &sphere_ttr_centers(first, second, radius), + hint, + )?; + self.torus_preview(center, radius, self.torus_defaults.minor_radius) + } + TorusStep::TubeRadius { + center, + major_radius, + } => self.torus_preview(center, major_radius, center.distance(local)), + TorusStep::TubeDiameter { + center, + major_radius, + } => self.torus_preview(center, major_radius, center.distance(local) * 0.5), + TorusStep::TubeTwoPointSecond { + center, + major_radius, + first, + } => self.torus_preview(center, major_radius, first.distance(local) * 0.5), + _ => None, + }; + } if self.shape == Shape::Sphere { let local = self.plane.to_local(pt); if !local.is_finite() { @@ -1945,6 +3089,9 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Cone { return self.cone_mouse_move(pt); } + if self.shape == Shape::Pyramid { + return self.pyramid_mouse_move(pt); + } if self.pts.is_empty() { return None; } @@ -2052,6 +3199,25 @@ impl CadCommand for PrimitiveCommand { fn dyn_spec(&self) -> Option { use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + if self.shape == Shape::Torus { + let (anchor, role) = match self.torus_step { + TorusStep::Radius(center) => (center, DynRole::Radius), + TorusStep::Diameter(center) => (center, DynRole::Diameter), + TorusStep::TtrRadius { second_hit, .. } => { + (second_hit, DynRole::Radius) + } + TorusStep::TubeRadius { center, .. } => (center, DynRole::Radius), + TorusStep::TubeDiameter { center, .. } => (center, DynRole::Diameter), + _ => return None, + }; + return Some(DynSpec { + anchor: DynAnchor::Point(self.plane.to_world(anchor)), + fields: vec![DynFieldSpec::new(role)], + guide: DynGuide::Radius, + ref_point: None, + }); + } + if self.shape == Shape::Sphere { let (anchor, role) = match self.sphere_step { SphereStep::Radius(center) => (center, DynRole::Radius), @@ -2098,6 +3264,32 @@ impl CadCommand for PrimitiveCommand { }); } + if self.shape == Shape::Pyramid { + let frame = self.pyramid_frame.unwrap_or(self.plane); + let (anchor, role) = match self.pyramid_step { + PyramidStep::BaseRadius | PyramidStep::TopRadius => { + (frame.origin, DynRole::Radius) + } + PyramidStep::Height + | PyramidStep::HeightAfterTopRadius + | PyramidStep::AxisEndpoint => (frame.origin, DynRole::Height), + PyramidStep::HeightSecondPoint => { + (self.pyramid_height_first?, DynRole::Height) + } + _ => return None, + }; + return Some(DynSpec { + anchor: DynAnchor::Point(anchor), + fields: vec![DynFieldSpec::new(role)], + guide: if role == DynRole::Radius { + DynGuide::Radius + } else { + DynGuide::None + }, + ref_point: None, + }); + } + let role = if self.shape.rectangular() { match self.box_step { BoxStep::CubeSize | BoxStep::Length => DynRole::Distance, @@ -2119,6 +3311,21 @@ impl CadCommand for PrimitiveCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { + if self.shape == Shape::Torus { + let local = self.plane.to_local(cursor); + return match self.torus_step { + TorusStep::Radius(center) | TorusStep::TubeRadius { center, .. } => { + Some(center.distance(local)) + } + TorusStep::Diameter(center) | TorusStep::TubeDiameter { center, .. } => { + Some(center.distance(local)) + } + TorusStep::TtrRadius { second_hit, .. } => { + Some(second_hit.distance(local)) + } + _ => None, + }; + } if self.shape == Shape::Sphere { let local = self.plane.to_local(cursor); return match self.sphere_step { @@ -2155,6 +3362,25 @@ impl CadCommand for PrimitiveCommand { _ => None, }; } + if self.shape == Shape::Pyramid { + return match self.pyramid_step { + PyramidStep::BaseRadius | PyramidStep::TopRadius => { + let frame = self.pyramid_frame?; + let delta = cursor - frame.origin; + Some((delta - frame.z * delta.dot(frame.z)).length()) + } + PyramidStep::Height | PyramidStep::HeightAfterTopRadius => { + self.pyramid_height_at(cursor).map(f64::abs) + } + PyramidStep::HeightSecondPoint => { + Some(self.pyramid_height_first?.distance(cursor)) + } + PyramidStep::AxisEndpoint => { + self.pyramid_frame.map(|frame| frame.origin.distance(cursor)) + } + _ => None, + }; + } if self.shape.rectangular() { let local = self.plane.to_local(cursor); if !local.is_finite() { @@ -2183,6 +3409,48 @@ impl CadCommand for PrimitiveCommand { // ── Footprint preview ─────────────────────────────────────────────────────── +fn torus_wire(center: DVec3, major_radius: f64, minor_radius: f64) -> WireModel { + let mut points = Vec::new(); + let profile_limit = if minor_radius > major_radius { + std::f64::consts::PI - (major_radius / minor_radius).acos() + } else { + std::f64::consts::PI + }; + + for fraction in [-0.75_f64, -0.5, 0.0, 0.5, 0.75] { + let angle = profile_limit * fraction; + let ring_radius = major_radius + minor_radius * angle.cos(); + if ring_radius <= 1e-9 { + continue; + } + if !points.is_empty() { + points.push([f32::NAN; 3]); + } + circle_points( + &mut points, + center + DVec3::Z * (minor_radius * angle.sin()), + ring_radius, + ); + } + + for meridian in 0..12 { + points.push([f32::NAN; 3]); + let around = meridian as f64 * std::f64::consts::TAU / 12.0; + for sample in 0..=32 { + let profile = -profile_limit + 2.0 * profile_limit * sample as f64 / 32.0; + let radial = (major_radius + minor_radius * profile.cos()).max(0.0); + let point = center + + DVec3::new( + radial * around.cos(), + radial * around.sin(), + minor_radius * profile.sin(), + ); + points.push(point.as_vec3().to_array()); + } + } + wire("torus_preview", points) +} + fn footprint_wire(shape: Shape, pts: &[DVec3]) -> WireModel { let mut points: Vec<[f32; 3]> = Vec::new(); if shape == Shape::Pyramid { @@ -2297,6 +3565,14 @@ fn push_loop(points: &mut Vec<[f32; 3]>, path: &[DVec3; N]) { points.extend(path.iter().chain(path.first()).map(|point| point.as_vec3().to_array())); } +fn push_path_loop(points: &mut Vec<[f32; 3]>, path: &[DVec3]) { + if path.is_empty() { + return; + } + push_break(points); + points.extend(path.iter().chain(path.first()).map(|point| point.as_vec3().to_array())); +} + fn push_segment(points: &mut Vec<[f32; 3]>, a: DVec3, b: DVec3) { push_break(points); points.extend([a.as_vec3().to_array(), b.as_vec3().to_array()]); diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index aaf12f4da..5e04a9613 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -140,12 +140,16 @@ pub enum LocalSub { Nested(NestedRef), } +#[derive(Clone, Copy, Debug, Default)] +pub struct BlockMetrics { + pub aabb_local: [f32; 4], + pub has_point_marker: bool, +} + #[derive(Clone, Debug, Default)] pub struct BlockDefn { pub subs: Vec, - pub has_point_marker: bool, - /// Union of direct and nested geometry in definition coordinates. - pub aabb_local: [f32; 4], + pub metrics: BlockMetrics, /// Raw entity count of the source block record (`entity_handles.len()`). /// Divisor for nested depth composition: a nested insert's children get a /// sub-range of `parent_scale / (child_count + 1)`, shared with the scene @@ -247,8 +251,8 @@ impl BlockCache { // by-name references (`LocalSub::Nested`), never expanded here, so a // block's build never depends on another block's defn. That makes the // builds embarrassingly parallel over the read-only `doc` — no - // topological ordering required. `compute_block_aabbs` stays a serial - // post-pass (it resolves nested references and is comparatively cheap). + // topological ordering required. Metrics are resolved after all + // definitions exist. cache.defns = referenced .par_iter() .map(|name| { @@ -267,7 +271,7 @@ impl BlockCache { ) }) .collect(); - cache.compute_block_aabbs(&referenced); + cache.compute_block_metrics(&referenced); cache } @@ -304,106 +308,73 @@ impl BlockCache { ) }) .collect(); - cache.compute_block_aabbs(&referenced); + cache.compute_block_metrics(&referenced); cache } - /// Compute and store the `aabb_local` for every cached defn. Direct wires - /// contribute their own aabb_local; nested INSERT references look up the - /// nested defn (already cached) and transform its aabb_local by the - /// nested Insert's transform before unioning. - /// - /// Run as a post-pass so it doesn't matter which order build_defn was - /// called in. Cycle guard: a self-referential block keeps an empty AABB - /// (will fail every frustum test → not emitted, which is correct). - fn compute_block_aabbs(&mut self, names: &[String]) { + /// Resolve recursive bounds and point-marker presence. + fn compute_block_metrics(&mut self, names: &[String]) { use crate::par::prelude::*; - // Phase 1 (parallel, read-only): each defn's union AABB is resolved - // by `defn_aabb_recursive`, which only *reads* `self.defns` (the map - // is fully built by now). There's no memoization, so a defn shared by - // many parents is re-walked per parent — real work on block-heavy - // drawings — and the per-name walks are independent, so they fan out. + // Definitions are complete, so each recursive read can run independently. let this: &Self = self; - let resolved: Vec<(&String, [f32; 4], bool)> = names + let resolved: Vec<(&String, BlockMetrics)> = names .par_iter() .map(|name| { let mut visited: Vec = Vec::new(); - let aabb = this.defn_aabb_recursive(name, &mut visited); - visited.clear(); - let has_marker = this.defn_has_point_marker_recursive(name, &mut visited); - (name, aabb, has_marker) + (name, this.defn_metrics_recursive(name, &mut visited)) }) .collect(); - // Phase 2 (serial): store the AABB back into each defn. - for (name, aabb, has_marker) in resolved { + for (name, metrics) in resolved { if let Some(defn_arc) = self.defns.get_mut(name) { let mut defn = (**defn_arc).clone(); - defn.aabb_local = aabb; - defn.has_point_marker = has_marker; + defn.metrics = metrics; *defn_arc = Arc::new(defn); } } } - fn defn_has_point_marker_recursive( + fn defn_metrics_recursive( &self, block_name: &str, visited: &mut Vec, - ) -> bool { + ) -> BlockMetrics { if visited.iter().any(|name| name == block_name) { - return false; + return BlockMetrics::default(); } let Some(defn) = self.defns.get(block_name) else { - return false; + return BlockMetrics::default(); }; visited.push(block_name.to_string()); - let found = defn.subs.iter().any(|sub| match sub { - LocalSub::Wire(wire) => wire.point_marker.is_some(), - LocalSub::Nested(insert) => { - insert - .attachments - .iter() - .any(|wire| wire.point_marker.is_some()) - || self.defn_has_point_marker_recursive(&insert.block_name, visited) - } - }); - visited.pop(); - found - } - - /// Returns the union AABB for `block_name`'s defn, expressed in **that - /// defn's offset frame** (so its caller can store it in - /// `BlockDefn.aabb_local` without a coordinate-frame mismatch). - /// - /// LocalWire contributions are already in the parent defn's offset - /// frame. Nested-INSERT contributions live in the *child* defn's offset - /// frame, so we re-add `child.local_offset` (f64), apply the nested - /// Insert's transform to get parent-native coordinates, then subtract - /// `parent.local_offset` to land back in the parent's offset frame. - fn defn_aabb_recursive(&self, block_name: &str, visited: &mut Vec) -> [f32; 4] { - if visited.iter().any(|n| n == block_name) { - return [0.0, 0.0, 0.0, 0.0]; - } - let Some(defn) = self.defns.get(block_name) else { - return [0.0, 0.0, 0.0, 0.0]; - }; - visited.push(block_name.to_string()); - let mut acc = [0.0_f32, 0.0, 0.0, 0.0]; + let mut metrics = BlockMetrics::default(); for sub in &defn.subs { - let aabb = match sub { - LocalSub::Wire(lw) => lw.aabb_local, - LocalSub::Nested(nref) => { - let nested = self.defn_aabb_recursive(&nref.block_name, visited); - nref.attachments.iter().fold( - transform_aabb_xy(nested, &nref.xform), - |aabb, wire| aabb_union(aabb, wire.aabb_local), - ) + match sub { + LocalSub::Wire(wire) => { + metrics.aabb_local = aabb_union(metrics.aabb_local, wire.aabb_local); + metrics.has_point_marker |= wire.point_marker.is_some(); } - }; - acc = aabb_union(acc, aabb); + LocalSub::Nested(nref) => { + let nested = self.defn_metrics_recursive(&nref.block_name, visited); + metrics.has_point_marker |= nested.has_point_marker; + for offset in &nref.instance_offsets { + let transform = nested_instance_transform(nref, *offset); + metrics.aabb_local = aabb_union( + metrics.aabb_local, + transform_aabb_xy(nested.aabb_local, &transform), + ); + let attachment_transform = nested_attachment_transform(nref, *offset); + for wire in &nref.attachments { + metrics.aabb_local = aabb_union( + metrics.aabb_local, + transform_aabb_xy(wire.aabb_local, &attachment_transform), + ); + metrics.has_point_marker |= wire.point_marker.is_some(); + } + } + } + } } visited.pop(); - acc + metrics } } @@ -479,6 +450,21 @@ fn layer_hidden(doc: &CadDocument, layer: &str) -> bool { .unwrap_or(false) } +fn nested_instance_transform(nref: &NestedRef, offset: [f64; 3]) -> Transform { + if offset == [0.0; 3] { + nref.xform.clone() + } else { + Transform::from_translation(Vector3::new(offset[0], offset[1], offset[2])) + .then(&nref.xform) + } +} + +fn nested_attachment_transform(nref: &NestedRef, offset: [f64; 3]) -> Transform { + let base = nref.xform.apply(Vector3::ZERO); + let instance = nested_instance_transform(nref, offset).apply(Vector3::ZERO); + Transform::from_translation(instance - base) +} + fn build_defn( doc: &CadDocument, block_name: &str, @@ -549,6 +535,7 @@ fn build_defn( for block_use in &block_uses { let mut nested = build_nested_ref( &block_use.insert, + block_use.scale_policy, doc, anno_scale, bg_color, @@ -597,14 +584,14 @@ fn build_defn( } BlockDefn { subs, - has_point_marker: false, - aabb_local: [0.0; 4], + metrics: BlockMetrics::default(), child_count: br.entity_handles.len(), } } fn build_nested_ref( nested_ins: &acadrust::entities::Insert, + scale_policy: crate::scene::BlockScalePolicy, doc: &CadDocument, anno_scale: f32, bg_color: [f32; 4], @@ -614,10 +601,11 @@ fn build_nested_ref( // Bake the XCLIP boundary (parent-defn-local) so the nested insert keeps // its clip when the parent block is expanded — the spatial filter object // isn't reachable at expand time. - let xform = crate::scene::render_graph::insert_transform_at_scale( + let xform = crate::scene::render_graph::insert_transform_with_policy( doc, nested_ins, anno_scale, + scale_policy, ); let clip_poly = crate::scene::pick::xclip::insert_spatial_filter(doc, nested_ins) .map(|filter| { @@ -635,6 +623,7 @@ fn build_nested_ref( doc, nested_ins, anno_scale, + scale_policy, ) .into_iter() .flat_map(|attribute| { @@ -977,12 +966,18 @@ pub fn expand_insert( // Current annotation scale. An annotative block scales as one uniform unit // about its insertion point; a non-annotative block is unaffected. anno_scale: f32, + scale_policy: crate::scene::BlockScalePolicy, // Dimension picture blocks store definition POINTs at their root. They are // metadata, not visible geometry; nested POINTs remain regular block data. suppress_root_points: bool, ) -> Option> { let defn = cache.defn(&ins.block_name)?; - let xform = crate::scene::render_graph::insert_transform_at_scale(doc, ins, anno_scale); + let xform = crate::scene::render_graph::insert_transform_with_policy( + doc, + ins, + anno_scale, + scale_policy, + ); let name = ins_handle.value().to_string(); let prototype_key = if !ins.is_array() && cache.prototype_blocks.contains(&ins.block_name) @@ -1042,10 +1037,22 @@ pub fn expand_insert( let mut batches = Batches::default(); let mut visited: Vec = Vec::with_capacity(8); - // `defn.aabb_local` is in the defn's offset frame — re-add - // `defn.local_offset` (f64) before transforming so the world AABB is - // accurate for distant content. - let insert_world = transform_aabb_xy(defn.aabb_local, &xform); + let offsets = crate::scene::render_graph::array_offsets(ins); + let cell_xform = |offset: &[f64; 3]| { + crate::scene::render_graph::insert_instance_transform( + doc, + ins, + *offset, + anno_scale, + scale_policy, + ) + }; + let insert_world = offsets.iter().fold([0.0_f32; 4], |aabb, offset| { + aabb_union( + aabb, + transform_aabb_xy(defn.metrics.aabb_local, &cell_xform(offset)), + ) + }); let insert_local = [ insert_world[0] as f32, insert_world[1] as f32, @@ -1062,7 +1069,7 @@ pub fn expand_insert( // Whole-Insert pixel-size LOD: if the entire Insert footprint projects // to sub-pixel size, skip it entirely. if let Some(wpp) = world_per_pixel { - if !defn.has_point_marker && aabb_pixel_size(insert_local, wpp) < MIN_PIXEL_SIZE { + if !defn.metrics.has_point_marker && aabb_pixel_size(insert_local, wpp) < MIN_PIXEL_SIZE { return Some(vec![]); } } @@ -1085,16 +1092,7 @@ pub fn expand_insert( is_xref, bg_color, }; - let offsets = crate::scene::render_graph::array_offsets(ins); if offsets.len() > 1 { - let cell_xform = |offset: &[f64; 3]| { - if offset == &[0.0; 3] { - xform.clone() - } else { - Transform::from_translation(Vector3::new(offset[0], offset[1], offset[2])) - .then(&xform) - } - }; let first_xform = cell_xform(&offsets[0]); let first_translation = transform_translation(&first_xform); let mut first_batches = Batches::default(); @@ -1135,14 +1133,7 @@ pub fn expand_insert( } for offset in &offsets { - let base_xform = if offset == &[0.0; 3] { - xform.clone() - } else { - let translation = Transform::from_translation(Vector3::new( - offset[0], offset[1], offset[2], - )); - translation.then(&xform) - }; + let base_xform = cell_xform(offset); expand_defn( defn, &base_xform, @@ -1816,8 +1807,18 @@ fn expand_defn( is_xref: ctx.is_xref, bg_color: ctx.bg_color, }; - for attachment in &nref.attachments { - emit_wire(attachment, accum_xform, &inner_ctx, out, d_range); + for offset in &nref.instance_offsets { + let attachment_transform = nested_attachment_transform(nref, *offset) + .then(accum_xform); + for attachment in &nref.attachments { + emit_wire( + attachment, + &attachment_transform, + &inner_ctx, + out, + d_range, + ); + } } if visited.iter().any(|n| n == &nref.block_name) { continue; @@ -1825,8 +1826,17 @@ fn expand_defn( let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else { continue; }; - let composed = nref.xform.then(accum_xform); - let world = transform_aabb_xy(nested_defn.aabb_local, &composed); + let world = nref.instance_offsets.iter().fold( + [0.0_f32; 4], + |aabb, offset| { + let composed = nested_instance_transform(nref, *offset) + .then(accum_xform); + aabb_union( + aabb, + transform_aabb_xy(nested_defn.metrics.aabb_local, &composed), + ) + }, + ); let local = [ world[0] as f32, world[1] as f32, @@ -1851,15 +1861,7 @@ fn expand_defn( d_range.1 / (nested_defn.child_count.max(1) as f32 + 1.0), ); let composed_for = |offset: &[f64; 3]| { - if offset == &[0.0; 3] { - nref.xform.then(accum_xform) - } else { - Transform::from_translation(Vector3::new( - offset[0], offset[1], offset[2], - )) - .then(&nref.xform) - .then(accum_xform) - } + nested_instance_transform(nref, *offset).then(accum_xform) }; if let Some(cp) = &nref.clip_poly { let base_composed = nref.xform.then(accum_xform); diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 317148c9b..30fbc7259 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -107,10 +107,11 @@ fn block_entity_transform( visited, annotation_scale, )?; - Some(inner.then(&crate::scene::render_graph::insert_transform_at_scale( + Some(inner.then(&crate::scene::render_graph::insert_transform_with_policy( document, &block_use.insert, annotation_scale, + block_use.scale_policy, ))) }) }); diff --git a/src/scene/convert/dgn_linestyle.rs b/src/scene/convert/dgn_linestyle.rs index 70ba4c6b5..6b7b4c041 100644 --- a/src/scene/convert/dgn_linestyle.rs +++ b/src/scene/convert/dgn_linestyle.rs @@ -144,8 +144,8 @@ pub fn symbol_radius(doc: &CadDocument, block: Handle, scale: f64) -> f64 { ) else { return 0.0; }; - graph.walk_insert( - &block_use.insert, + graph.walk_block_use( + &block_use, block, |_, _| true, |entity, context| { @@ -322,16 +322,15 @@ pub fn place_block_wires( ) else { return Vec::new(); }; - let insert = &mut block_use.insert; - insert.set_x_scale(s); - insert.set_y_scale(s); - insert.set_z_scale(s); + block_use.insert.set_x_scale(s); + block_use.insert.set_y_scale(s); + block_use.insert.set_z_scale(s); let depths = rustc_hash::FxHashMap::default(); let graph = crate::scene::render_graph::RenderSceneGraph::new(doc, None, None, true, &depths) .with_annotation_scale(anno_scale); let mut out = Vec::new(); - graph.walk_insert( - insert, + graph.walk_block_use( + &block_use, block, |_, _| true, |entity, context| { diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index f6a78a726..b877827dd 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -39,6 +39,7 @@ pub(crate) struct ExpandedBlockObject { pub(crate) struct BlockObjectOptions { pub(crate) suppress_root_points: bool, pub(crate) apply_xclip: bool, + pub(crate) scale_policy: crate::scene::BlockScalePolicy, } fn block_object_style( @@ -141,16 +142,18 @@ pub(crate) fn expand_block_object( is_xref, bg_color, anno_scale, + options.scale_policy, options.suppress_root_points, ) .unwrap_or_default(); if options.apply_xclip { if let Some(filter) = pick::xclip::insert_spatial_filter(document, insert) { - let transform = crate::scene::render_graph::insert_transform_at_scale( + let transform = crate::scene::render_graph::insert_transform_with_policy( document, insert, anno_scale, + options.scale_policy, ); let polygon = pick::xclip::world_clip_polygon_for_transform(filter, &transform); pick::xclip::clip_wires(&mut wires, &polygon); @@ -568,7 +571,7 @@ fn tessellate_entity_inner( // True only when tessellating content shown inside a paper-space viewport. // Retained through recursive INSERT expansion; PSLTSCALE itself is applied // by the viewport's GPU uniform so it never changes resident wire content. - paper_space: bool, + _paper_space: bool, ) -> Vec { let contextual = crate::scene::annotative::entity_for_annotation_context( document, @@ -980,6 +983,7 @@ fn tessellate_entity_inner( pslt_factor, BlockObjectOptions { suppress_root_points: block_use.suppress_root_points, + scale_policy: block_use.scale_policy, ..BlockObjectOptions::default() }, ) @@ -1087,7 +1091,10 @@ fn tessellate_entity_inner( view_aabb, world_per_pixel, pslt_factor, - BlockObjectOptions::default(), + BlockObjectOptions { + scale_policy: block_use.scale_policy, + ..BlockObjectOptions::default() + }, ) .wires; if !wires.is_empty() { @@ -1119,19 +1126,27 @@ fn tessellate_entity_inner( .filter(|block_use| { block_use.role == crate::scene::render_graph::BlockRole::TableCell }) { - wires.extend(tessellate_entity( - document, - selected, - active_viewport, - bg_color, - 1.0, - None, - &EntityType::Insert(block_use.insert), - block_cache, - view_aabb, - world_per_pixel, - paper_space, - )); + wires.extend( + expand_block_object( + document, + &block_use.insert, + h, + sel, + active_viewport, + bg_color, + table_anno, + None, + block_cache, + view_aabb, + world_per_pixel, + pslt_factor, + BlockObjectOptions { + scale_policy: block_use.scale_policy, + ..BlockObjectOptions::default() + }, + ) + .wires, + ); } if !wires.is_empty() { let aabb = entity_aabb(e); diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index aa131ce88..6bf477734 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -2100,8 +2100,8 @@ fn custom_arrow_from_block( let mut lines = Vec::new(); let mut fill = Vec::new(); let mut deferred_hatch = false; - graph.walk_insert( - &block_use.insert, + graph.walk_block_use( + &block_use, record.handle, |_, _| true, |entity, context| { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 908a59100..85f25954e 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1183,7 +1183,7 @@ impl Scene { fn layer_plottable_in_context( &self, entity: &EntityType, - context: &crate::scene::render_graph::RenderContext, + context: &crate::scene::render_graph::InstanceContext, ) -> bool { let common = entity.common(); let layer = if crate::scene::view::render::is_effective_layer_zero(&common.layer) { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e54209b74..13f5bf6c2 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -17,6 +17,13 @@ pub(crate) mod render_graph; pub mod text; pub mod view; +#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] +pub enum BlockScalePolicy { + #[default] + FromInsert, + Applied, +} + // Topic submodules split out of this root (each contributes `impl Scene` // blocks and/or free functions). Pure text-move from the original mod.rs. mod boundary; @@ -66,6 +73,13 @@ struct DependencyTargets { touches_block_definition: bool, } +#[derive(Clone, Copy)] +enum DependencyKind { + Layer, + TextStyle, + DimStyle, +} + #[derive(Default)] struct SceneDependencyIndex { layers: HashMap, @@ -75,6 +89,7 @@ struct SceneDependencyIndex { points: DependencyTargets, text_geometry: DependencyTargets, annotation_geometry: DependencyTargets, + signatures: HashMap, } fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64; 4]> { @@ -164,6 +179,7 @@ use glam; use iced::time::Duration; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::cell::RefCell; +use std::hash::{Hash, Hasher}; use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -2429,11 +2445,7 @@ impl Scene { } } if !changes.is_empty() { - // A Modified delta can change layer/style/block references as well - // as coordinates. Rebuild the reverse dependency map lazily on its - // next use so later targeted global-property updates never follow - // stale ownership. - self.invalidate_dependency_index(); + self.refresh_dependency_index_for_changes(&changes); } let cached_light_changed = self.lighting_cache.borrow().values().any(|lights| { changes @@ -2533,6 +2545,7 @@ impl Scene { // Structural change — drop both per-entity tessellation memos. self.tess_memo.borrow_mut().clear(); self.resident_tess_memo.borrow_mut().clear(); + self.invalidate_dependency_index(); // Can't name the changed handles → force every journal consumer to rebuild. self.push_geometry_delta(epoch, Vec::new(), true); } @@ -2616,6 +2629,7 @@ impl Scene { let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; self.lighting_cache.borrow_mut().clear(); + self.invalidate_dependency_index(); self.push_geometry_delta(epoch, Vec::new(), true); } @@ -7132,8 +7146,8 @@ impl Scene { ) { continue; } - graph.walk_insert( - &block_use.insert, + graph.walk_block_use( + &block_use, common.handle, |_, _| true, |entity, context| { @@ -7387,7 +7401,10 @@ impl Scene { let changed_live: Vec = changes .iter() .filter_map(|(handle, kind)| { - (!matches!(kind, ChangeKind::Removed)).then_some(*handle) + (!matches!(kind, ChangeKind::Removed) + && !self.entity_temporarily_hidden(*handle) + && self.document.get_entity(*handle).is_some()) + .then_some(*handle) }) .collect(); let wires = Arc::new(self.wire_models_for(&changed_live)); @@ -8847,6 +8864,113 @@ impl Scene { }) } + fn entity_dependency_signature(&self, entity: &EntityType) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let common = entity.common(); + common.handle.hash(&mut hasher); + common.owner_handle.hash(&mut hasher); + normalize_name(&common.layer).hash(&mut hasher); + + let category = match entity { + EntityType::Point(_) => 1_u8, + EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Dimension(_) + | EntityType::Leader(_) + | EntityType::Tolerance(_) + | EntityType::MultiLeader(_) + | EntityType::Table(_) + | EntityType::Shape(_) => 2, + EntityType::Hatch(_) => 3, + EntityType::Insert(insert) if !insert.attributes.is_empty() => 4, + _ => 0, + }; + category.hash(&mut hasher); + crate::scene::annotative::is_annotative(&self.document, entity).hash(&mut hasher); + + for block_use in render_graph::entity_block_uses(&self.document, entity, 1.0) { + block_use.block.hash(&mut hasher); + normalize_name(&block_use.insert.block_name).hash(&mut hasher); + block_use.role.hash(&mut hasher); + block_use.scale_policy.hash(&mut hasher); + block_use.active.hash(&mut hasher); + } + + match entity { + EntityType::Text(text) => normalize_name(&text.style).hash(&mut hasher), + EntityType::MText(text) => normalize_name(&text.style).hash(&mut hasher), + EntityType::AttributeDefinition(attribute) => { + normalize_name(&attribute.text_style).hash(&mut hasher) + } + EntityType::AttributeEntity(attribute) => { + normalize_name(&attribute.text_style).hash(&mut hasher) + } + EntityType::Insert(insert) => { + for attribute in &insert.attributes { + normalize_name(&attribute.common.layer).hash(&mut hasher); + normalize_name(&attribute.text_style).hash(&mut hasher); + } + } + EntityType::Dimension(dimension) => { + normalize_name(&dimension.base().style_name).hash(&mut hasher) + } + EntityType::Leader(leader) => { + normalize_name(&leader.dimension_style).hash(&mut hasher) + } + EntityType::Tolerance(tolerance) => { + normalize_name(&tolerance.dimension_style_name).hash(&mut hasher) + } + EntityType::Table(table) => { + table.table_style_handle.hash(&mut hasher); + for row in &table.rows { + row.style + .as_ref() + .and_then(|style| style.text_style_handle) + .hash(&mut hasher); + for cell in &row.cells { + cell.style + .as_ref() + .and_then(|style| style.text_style_handle) + .hash(&mut hasher); + for content in &cell.contents { + content.text_style_handle.hash(&mut hasher); + } + } + } + } + EntityType::MultiLeader(leader) => { + leader.style_handle.hash(&mut hasher); + leader.text_style_handle.hash(&mut hasher); + leader.context.text_style_handle.hash(&mut hasher); + } + EntityType::MLine(line) => line.style_handle.hash(&mut hasher), + _ => {} + } + hasher.finish() + } + + fn refresh_dependency_index_for_changes(&self, changes: &[(Handle, ChangeKind)]) { + let mut cache = self.dependency_index_cache.borrow_mut(); + let Some(index) = cache.as_ref() else { + return; + }; + let unchanged = changes.iter().all(|(handle, kind)| { + if !matches!(kind, ChangeKind::Modified) { + return false; + } + let Some(entity) = self.document.get_entity(*handle) else { + return false; + }; + index.signatures.get(handle).copied() + == Some(self.entity_dependency_signature(entity)) + }); + if !unchanged { + cache.take(); + } + } + fn rebuild_dependency_index(&self) -> SceneDependencyIndex { let layout_blocks: HashSet = self .document @@ -8928,6 +9052,9 @@ impl Scene { let mut index = SceneDependencyIndex::default(); for entity in self.document.entities() { let common = entity.common(); + index + .signatures + .insert(common.handle, self.entity_dependency_signature(entity)); let owner = if common.owner_handle.is_null() { membership .get(&common.handle) @@ -9102,17 +9229,20 @@ impl Scene { index } - fn dependency_targets(&self, kind: &str, names: &[String]) -> DependencyTargets { + fn dependency_targets( + &self, + kind: DependencyKind, + names: &[String], + ) -> DependencyTargets { if self.dependency_index_cache.borrow().is_none() { *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); } let cache = self.dependency_index_cache.borrow(); let index = cache.as_ref().unwrap(); let map = match kind { - "layer" => &index.layers, - "text" => &index.text_styles, - "dim" => &index.dim_styles, - _ => unreachable!(), + DependencyKind::Layer => &index.layers, + DependencyKind::TextStyle => &index.text_styles, + DependencyKind::DimStyle => &index.dim_styles, }; let mut combined = DependencyTargets::default(); for name in names { @@ -9148,7 +9278,7 @@ impl Scene { } pub fn invalidate_layer_dependencies(&mut self, names: &[String]) { - let targets = self.dependency_targets("layer", names); + let targets = self.dependency_targets(DependencyKind::Layer, names); self.invalidate_dependency_targets(targets); } @@ -9157,7 +9287,7 @@ impl Scene { } pub fn invalidate_text_style_dependencies_many(&mut self, names: &[String]) { - let targets = self.dependency_targets("text", names); + let targets = self.dependency_targets(DependencyKind::TextStyle, names); self.invalidate_dependency_targets(targets); } @@ -9166,7 +9296,7 @@ impl Scene { } pub fn invalidate_dim_style_dependencies_many(&mut self, names: &[String]) { - let targets = self.dependency_targets("dim", names); + let targets = self.dependency_targets(DependencyKind::DimStyle, names); self.invalidate_dependency_targets(targets); } diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index d1f2a59b3..8c5d838da 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -21,7 +21,8 @@ pub const GRIP_INNER_RADIUS: usize = 10_006; pub const GRIP_SIDES: usize = 10_007; pub const GRIP_MAJOR_RADIUS: usize = 10_008; pub const GRIP_MINOR_RADIUS: usize = 10_009; -pub const GRIP_POSITION: usize = 10_010; +pub const GRIP_TOP_RADIUS: usize = 10_010; +pub const GRIP_POSITION: usize = 10_011; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -51,6 +52,7 @@ pub const PROP_POSITION_X: &str = "solid_history_position_x"; pub const PROP_POSITION_Y: &str = "solid_history_position_y"; pub const PROP_POSITION_Z: &str = "solid_history_position_z"; pub const PROP_ROTATION: &str = "solid_history_rotation"; +pub const PROP_PYRAMID_TYPE: &str = "solid_history_pyramid_type"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; @@ -100,6 +102,8 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Sphere(_) | SolidHistoryOperation::Cone(_) | SolidHistoryOperation::Cylinder(_) + | SolidHistoryOperation::Torus(_) + | SolidHistoryOperation::Pyramid(_) | SolidHistoryOperation::Sweep(_) ) ) @@ -124,6 +128,8 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option value.reference_point.z, ], ), + SolidHistoryOperation::Torus(value) => world_point(value.base.transform, [0.0; 3]), + SolidHistoryOperation::Pyramid(value) => world_point(value.base.transform, [0.0; 3]), _ => None, } } @@ -133,13 +139,19 @@ fn sweep_properties( handle: acadrust::Handle, value: &SolidHistorySweep, ) -> Vec { - let Some(position) = reference_point(&SolidHistoryOperation::Sweep(value.clone())) else { + let Some(position) = world_point( + value.base.transform, + [ + value.reference_point.x, + value.reference_point.y, + value.reference_point.z, + ], + ) else { return Vec::new(); }; let (record_history, object_show_history, show_history_mode) = history_flags(document, handle).unwrap_or((false, false, 1)); - let (show_history, _) = - displayed_history_state(object_show_history, show_history_mode); + let (show_history, _) = displayed_history_state(object_show_history, show_history_mode); let show_value = if show_history { "Yes" } else { "No" }; vec![ PropSection { @@ -187,6 +199,75 @@ fn sweep_properties( ] } +fn torus_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryTorus, +) -> Vec { + let Some(position) = world_point(value.base.transform, [0.0; 3]) else { + return Vec::new(); + }; + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, _) = displayed_history_state(object_show_history, show_history_mode); + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("Torus").into_owned()), + }, + crate::entities::common::edit_prop( + t!("Position X").as_ref(), + PROP_POSITION_X, + position.x, + ), + crate::entities::common::edit_prop( + t!("Position Y").as_ref(), + PROP_POSITION_Y, + position.y, + ), + crate::entities::common::edit_prop( + t!("Position Z").as_ref(), + PROP_POSITION_Z, + position.z, + ), + history_prop( + t!("Torus radius").as_ref(), + PROP_MAJOR_RADIUS, + value.major_radius, + ), + history_prop( + t!("Tube radius").as_ref(), + PROP_MINOR_RADIUS, + value.minor_radius, + ), + ], + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::ReadOnly( + if record_history { "Record" } else { "None" }.to_string(), + ), + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: PropValue::ReadOnly( + if show_history { "Yes" } else { "No" }.to_string(), + ), + }, + ], + }, + ] +} + fn cone_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -433,6 +514,132 @@ fn cylinder_properties( ] } +fn pyramid_is_inscribed(value: &SolidHistoryPyramid) -> bool { + value.operation_minor == -1 +} + +fn pyramid_apothem_factor(sides: i32) -> f64 { + (std::f64::consts::PI / sides.clamp(3, 32) as f64).cos() +} + +fn pyramid_display_radius(value: &SolidHistoryPyramid, radius: f64) -> f64 { + if pyramid_is_inscribed(value) { + radius + } else { + radius * pyramid_apothem_factor(value.sides) + } +} + +fn pyramid_display_rotation(value: &SolidHistoryPyramid) -> Option { + let vertex_rotation = matrix(value.base.transform) + .map(|matrix| matrix.x_axis.y.atan2(matrix.x_axis.x))?; + if pyramid_is_inscribed(value) { + Some(vertex_rotation) + } else { + Some(vertex_rotation + std::f64::consts::PI / value.sides.clamp(3, 32) as f64) + } +} + +fn pyramid_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryPyramid, +) -> Vec { + let Some(position) = world_point(value.base.transform, [0.0; 3]) else { + return Vec::new(); + }; + let rotation = pyramid_display_rotation(value).unwrap_or(0.0); + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); + let show_value = if show_history { "Yes" } else { "No" }; + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("Pyramid").into_owned()), + }, + crate::entities::common::edit_prop( + t!("Position X").as_ref(), + PROP_POSITION_X, + position.x, + ), + crate::entities::common::edit_prop( + t!("Position Y").as_ref(), + PROP_POSITION_Y, + position.y, + ), + crate::entities::common::edit_prop( + t!("Position Z").as_ref(), + PROP_POSITION_Z, + position.z, + ), + Property { + label: t!("Type").into_owned(), + field: PROP_PYRAMID_TYPE, + value: PropValue::Choice { + selected: if pyramid_is_inscribed(value) { + "Inscribed" + } else { + "Circumscribed" + } + .to_string(), + options: vec!["Circumscribed".to_string(), "Inscribed".to_string()], + }, + }, + history_prop( + t!("Base radius").as_ref(), + PROP_BASE_RADIUS, + pyramid_display_radius(value, value.radius), + ), + history_prop( + t!("Top radius").as_ref(), + PROP_TOP_RADIUS, + pyramid_display_radius(value, value.top_radius), + ), + history_prop(t!("Sides").as_ref(), PROP_SIDES, value.sides), + Property { + label: t!("Rotation").into_owned(), + field: PROP_ROTATION, + value: PropValue::EditText( + crate::entities::common::format_direction(rotation), + ), + }, + history_prop(t!("Height").as_ref(), PROP_HEIGHT, value.height), + ], + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::Choice { + selected: if record_history { "Record" } else { "None" }.to_string(), + options: vec!["None".to_string(), "Record".to_string()], + }, + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: if show_history_editable { + PropValue::Choice { + selected: show_value.to_string(), + options: vec!["No".to_string(), "Yes".to_string()], + } + } else { + PropValue::ReadOnly(show_value.to_string()) + }, + }, + ], + }, + ] +} + fn sphere_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -613,46 +820,21 @@ pub fn primitive_properties( let Some(operation) = document.solid_history_operation(handle) else { return Vec::new(); }; - let props = match operation { + match operation { SolidHistoryOperation::Box(value) => { - return rectangular_properties(document, handle, value, "Box") + rectangular_properties(document, handle, value, "Box") } SolidHistoryOperation::Wedge(value) => { - return rectangular_properties(document, handle, value, "Wedge") + rectangular_properties(document, handle, value, "Wedge") } - SolidHistoryOperation::Sphere(value) => { - return sphere_properties(document, handle, value) - } - SolidHistoryOperation::Cone(value) => return cone_properties(document, handle, value), - SolidHistoryOperation::Cylinder(value) => { - return cylinder_properties(document, handle, value) - } - SolidHistoryOperation::Sweep(value) => { - return sweep_properties(document, handle, value) - } - SolidHistoryOperation::Torus(value) => vec![ - history_prop( - t!("Outer Radius").as_ref(), - PROP_OUTER_RADIUS, - value.major_radius + value.minor_radius, - ), - history_prop( - t!("Inner Radius").as_ref(), - PROP_INNER_RADIUS, - (value.major_radius - value.minor_radius).max(0.0), - ), - ], - SolidHistoryOperation::Pyramid(value) => vec![ - history_prop(t!("Radius").as_ref(), PROP_RADIUS, value.radius), - history_prop(t!("Height").as_ref(), PROP_HEIGHT, value.height), - history_prop(t!("Sides").as_ref(), PROP_SIDES, value.sides), - ], - _ => return Vec::new(), - }; - vec![PropSection { - title: t!("Primitive").into_owned(), - props, - }] + SolidHistoryOperation::Sphere(value) => sphere_properties(document, handle, value), + SolidHistoryOperation::Cone(value) => cone_properties(document, handle, value), + SolidHistoryOperation::Cylinder(value) => cylinder_properties(document, handle, value), + SolidHistoryOperation::Pyramid(value) => pyramid_properties(document, handle, value), + SolidHistoryOperation::Torus(value) => torus_properties(document, handle, value), + SolidHistoryOperation::Sweep(value) => sweep_properties(document, handle, value), + _ => Vec::new(), + } } pub fn is_primitive_property(field: &str) -> bool { @@ -678,6 +860,7 @@ pub fn is_primitive_property(field: &str) -> bool { | PROP_POSITION_Y | PROP_POSITION_Z | PROP_ROTATION + | PROP_PYRAMID_TYPE ) } @@ -918,6 +1101,36 @@ fn apply_sweep_position_property( Some(true) } +fn apply_torus_position_property( + value: &mut SolidHistoryTorus, + field: &str, + text: &str, +) -> Option { + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + PROP_POSITION_Z => 2, + _ => return None, + }; + let target = crate::entities::common::parse_length(text)?; + if !target.is_finite() { + return Some(false); + } + let current = matrix(value.base.transform)?; + let origin = current.transform_point3(glam::DVec3::ZERO); + if !origin.is_finite() || (target - origin[axis]).abs() <= 1e-12 { + return Some(false); + } + let mut delta = glam::DVec3::ZERO; + delta[axis] = target - origin[axis]; + let updated = glam::DMat4::from_translation(delta) * current; + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + Some(true) +} + fn canonicalize_cylinder_radii(value: &mut SolidHistoryCylinder) -> bool { if value.minor_radius <= value.major_radius { value.x_radius = value.major_radius; @@ -933,6 +1146,120 @@ fn canonicalize_cylinder_radii(value: &mut SolidHistoryCylinder) -> bool { true } +fn apply_pyramid_geometry_property( + value: &mut SolidHistoryPyramid, + field: &str, + text: &str, +) -> Option { + if field == PROP_PYRAMID_TYPE { + let next_inscribed = if text.eq_ignore_ascii_case("Inscribed") { + true + } else if text.eq_ignore_ascii_case("Circumscribed") { + false + } else { + return Some(false); + }; + let old_inscribed = pyramid_is_inscribed(value); + if next_inscribed == old_inscribed { + return Some(false); + } + let factor = pyramid_apothem_factor(value.sides); + if factor <= 1e-9 { + return Some(false); + } + let base_display = if old_inscribed { value.radius } else { value.radius * factor }; + let top_display = if old_inscribed { + value.top_radius + } else { + value.top_radius * factor + }; + let Some(current) = matrix(value.base.transform) else { + return Some(false); + }; + value.radius = if next_inscribed { base_display } else { base_display / factor }; + value.top_radius = if next_inscribed { top_display } else { top_display / factor }; + let half = std::f64::consts::PI / value.sides.clamp(3, 32) as f64; + let delta = if next_inscribed { half } else { -half }; + value.base.transform = + (current * glam::DMat4::from_rotation_z(delta)).to_cols_array(); + value.operation_minor = if next_inscribed { -1 } else { 0 }; + return Some(true); + } + if field == PROP_ROTATION { + let target = crate::entities::common::parse_direction(text)?; + if !target.is_finite() { + return Some(false); + } + let current = matrix(value.base.transform)?; + let projected = current.x_axis.truncate(); + if projected.length_squared() <= 1e-12 { + return Some(false); + } + let current_angle = projected.y.atan2(projected.x); + let target_vertex_angle = if pyramid_is_inscribed(value) { + target + } else { + target - std::f64::consts::PI / value.sides.clamp(3, 32) as f64 + }; + let delta = (target_vertex_angle - current_angle + std::f64::consts::PI) + .rem_euclid(std::f64::consts::TAU) + - std::f64::consts::PI; + if delta.abs() <= 1e-12 { + return Some(false); + } + let updated = current * glam::DMat4::from_rotation_z(delta); + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + return Some(true); + } + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + PROP_POSITION_Z => 2, + _ => return None, + }; + let target = crate::entities::common::parse_length(text)?; + if !target.is_finite() { + return Some(false); + } + let current = matrix(value.base.transform)?; + let origin = current.transform_point3(glam::DVec3::ZERO); + if !origin.is_finite() || (target - origin[axis]).abs() <= 1e-12 { + return Some(false); + } + let mut delta = glam::DVec3::ZERO; + delta[axis] = target - origin[axis]; + let updated = glam::DMat4::from_translation(delta) * current; + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + Some(true) +} + +fn set_pyramid_sides(value: &mut SolidHistoryPyramid, sides: i32) -> bool { + if !(3..=32).contains(&sides) || sides == value.sides { + return false; + } + if !pyramid_is_inscribed(value) { + let Some(current) = matrix(value.base.transform) else { + return false; + }; + let old_factor = pyramid_apothem_factor(value.sides); + let new_factor = pyramid_apothem_factor(sides); + value.radius = value.radius * old_factor / new_factor; + value.top_radius = value.top_radius * old_factor / new_factor; + let old_half = std::f64::consts::PI / value.sides.clamp(3, 32) as f64; + let new_half = std::f64::consts::PI / sides as f64; + value.base.transform = + (current * glam::DMat4::from_rotation_z(old_half - new_half)).to_cols_array(); + } + value.sides = sides; + true +} + pub fn apply_primitive_property( operation: &mut SolidHistoryOperation, field: &str, @@ -967,6 +1294,16 @@ pub fn apply_primitive_property( return applied; } } + if let SolidHistoryOperation::Torus(torus_value) = operation { + if let Some(applied) = apply_torus_position_property(torus_value, field, value) { + return applied; + } + } + if let SolidHistoryOperation::Pyramid(pyramid_value) = operation { + if let Some(applied) = apply_pyramid_geometry_property(pyramid_value, field, value) { + return applied; + } + } let Some(number) = (if field == PROP_SIDES { value.trim().parse::().ok() } else { @@ -1091,46 +1428,55 @@ pub fn apply_primitive_property( } value.radius = radius; } - SolidHistoryOperation::Torus(value) => { - let outer = value.major_radius + value.minor_radius; - let inner = (value.major_radius - value.minor_radius).max(0.0); - match field { - PROP_OUTER_RADIUS => { - let Some(next_outer) = positive() else { - return false; - }; - if next_outer <= inner { - return false; - } - value.major_radius = (next_outer + inner) * 0.5; - value.minor_radius = (next_outer - inner) * 0.5; + SolidHistoryOperation::Torus(value) => match field { + PROP_MAJOR_RADIUS => { + let Some(radius) = positive() else { + return false; + }; + if (radius - value.major_radius).abs() <= 1e-12 { + return false; } - PROP_INNER_RADIUS => { - let Some(next_inner) = positive() else { - return false; - }; - if next_inner >= outer { - return false; - } - value.major_radius = (outer + next_inner) * 0.5; - value.minor_radius = (outer - next_inner) * 0.5; - } - _ => return false, + value.major_radius = radius; } - } + PROP_MINOR_RADIUS => { + let Some(radius) = positive() else { + return false; + }; + if (radius - value.minor_radius).abs() <= 1e-12 { + return false; + } + value.minor_radius = radius; + } + _ => return false, + }, SolidHistoryOperation::Pyramid(value) => match field { - PROP_RADIUS => value.radius = positive().unwrap_or(value.radius), + PROP_BASE_RADIUS => { + let Some(displayed) = positive() else { + return false; + }; + value.radius = if pyramid_is_inscribed(value) { + displayed + } else { + displayed / pyramid_apothem_factor(value.sides) + }; + } + PROP_TOP_RADIUS => { + if number < 0.0 { + return false; + } + value.top_radius = if pyramid_is_inscribed(value) { + number + } else { + number / pyramid_apothem_factor(value.sides) + }; + } PROP_HEIGHT => value.height = positive().unwrap_or(value.height), PROP_SIDES => { let rounded = number.round(); if (number - rounded).abs() > 1e-9 { return false; } - let sides = rounded as i32; - if !(3..=71).contains(&sides) { - return false; - } - value.sides = sides; + return set_pyramid_sides(value, rounded as i32); } _ => return false, }, @@ -1403,22 +1749,18 @@ pub fn primitive_grips( ), SolidHistoryOperation::Torus(value) => { add( - GRIP_OUTER_RADIUS, + GRIP_MAJOR_RADIUS, value.base.transform, - [value.major_radius + value.minor_radius, 0.0, 0.0], + [value.major_radius, 0.0, 0.0], GripShape::Square, None, ); add( - GRIP_INNER_RADIUS, + GRIP_MINOR_RADIUS, value.base.transform, - [ - (value.major_radius - value.minor_radius).max(0.0), - 0.0, - 0.0, - ], + [value.major_radius, 0.0, value.minor_radius], GripShape::Square, - None, + Some([0.0, 0.0, 1.0]), ); } SolidHistoryOperation::Pyramid(value) => { @@ -1429,6 +1771,13 @@ pub fn primitive_grips( GripShape::Square, None, ); + add( + GRIP_TOP_RADIUS, + value.base.transform, + [value.top_radius, 0.0, value.height], + GripShape::Square, + None, + ); add( GRIP_HEIGHT, value.base.transform, @@ -1436,7 +1785,7 @@ pub fn primitive_grips( GripShape::Square, Some([0.0, 0.0, 1.0]), ); - let angle = (value.sides.clamp(3, 71) as f64 * 5.0).to_radians(); + let angle = (value.sides.clamp(3, 32) as f64 * 5.0).to_radians(); add( GRIP_SIDES, value.base.transform, @@ -1580,33 +1929,22 @@ pub fn apply_primitive_grip( value.radius = local.length().max(1e-6); } SolidHistoryOperation::Torus(value) => match grip_id { - GRIP_OUTER_RADIUS => { - let outer = local.x.hypot(local.y).max(1e-6); - let inner = (value.major_radius - value.minor_radius).max(1e-6); - if outer <= inner { - return false; - } - value.major_radius = (outer + inner) * 0.5; - value.minor_radius = (outer - inner) * 0.5; - } - GRIP_INNER_RADIUS => { - let inner = local.x.hypot(local.y).max(1e-6); - let outer = value.major_radius + value.minor_radius; - if inner >= outer { - return false; - } - value.major_radius = (outer + inner) * 0.5; - value.minor_radius = (outer - inner) * 0.5; - } + GRIP_MAJOR_RADIUS => value.major_radius = local.x.hypot(local.y).max(1e-6), + GRIP_MINOR_RADIUS => value.minor_radius = local.z.abs().max(1e-6), _ => return false, }, SolidHistoryOperation::Pyramid(value) => match grip_id { GRIP_RADIUS => value.radius = local.x.hypot(local.y).max(1e-6), + GRIP_TOP_RADIUS => { + value.top_radius = local.x.hypot(local.y).max(0.0); + } GRIP_HEIGHT => value.height = local.z.max(1e-6), GRIP_SIDES => { let angle = local.y.atan2(local.x).rem_euclid(std::f64::consts::TAU); - value.sides = (angle.to_degrees() / 5.0).round() as i32; - value.sides = value.sides.clamp(3, 71); + let sides = ((angle.to_degrees() / 5.0).round() as i32).clamp(3, 32); + if !set_pyramid_sides(value, sides) { + return false; + } } _ => return false, }, @@ -1746,15 +2084,19 @@ pub fn torus_op( pub fn pyramid_op( transform: [f64; 16], radius: f64, + top_radius: f64, height: f64, sides: usize, + inscribed: bool, ) -> SolidHistoryOperation { SolidHistoryOperation::Pyramid(SolidHistoryPyramid { base: base(transform), operation_major: 1, + operation_minor: if inscribed { -1 } else { 0 }, height, sides: sides as i32, radius, + top_radius, ..SolidHistoryPyramid::default() }) } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 335c3377f..582037219 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -82,6 +82,17 @@ pub fn pyramid_solid(center: [f64; 3], radius: f64, height: f64, sides: usize) - brep::make::pyramid(center, radius, height, sides) } +/// Solid pyramid or polygonal frustum on a regular polygon of `sides` corners. +pub fn pyramid_frustum_solid( + center: [f64; 3], + base_radius: f64, + top_radius: f64, + height: f64, + sides: usize, +) -> Option { + brep::make::pyramid_frustum(center, base_radius, top_radius, height, sides) +} + // ── Placement ─────────────────────────────────────────────────────────────── /// Moves a body by a rigid transform, given as three axes and an origin. diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 9cc75bd02..247a8e5d1 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -181,29 +181,7 @@ fn embedded_path(entity: &EntityType) -> Option { EntityType::Circle(value) => Some(EmbeddedEntity::Circle(value.clone())), EntityType::Ellipse(value) => Some(EmbeddedEntity::Ellipse(value.clone())), EntityType::LwPolyline(value) => Some(EmbeddedEntity::LwPolyline(value.clone())), - EntityType::Spline(_) => { - let planar = entity_curve(entity)?; - let samples = planar.curve.tessellate_within(1e-4); - if samples.len() < 2 { - return None; - } - let mut path = LwPolyline::new(); - path.vertices = samples - .into_iter() - .map(|point| LwVertex::new(Vector2::new(point[0], point[1]))) - .collect(); - path.is_closed = planar.curve.is_closed(); - let plane = crate::command::WorkingPlane::new( - glam::DVec3::from_array(planar.plane.origin), - glam::DVec3::from_array(planar.plane.x_axis), - glam::DVec3::from_array(planar.plane.y_axis), - ); - let EntityType::LwPolyline(path) = plane.place_entity(EntityType::LwPolyline(path)) - else { - return None; - }; - Some(EmbeddedEntity::LwPolyline(path)) - } + EntityType::Spline(value) => Some(EmbeddedEntity::Spline(value.clone())), _ => None, } } @@ -231,7 +209,7 @@ pub fn polysolid( + Vec3::from(planar.plane.y_axis) * tangent_local.y) .normalize()?; let normal = Vec3::from(planar.plane.normal()?).normalize()?; - let side = tangent.cross(normal).normalize()?; + let side = normal.cross(tangent).normalize()?; let offset = match justification { PolysolidJustification::Left => 0.0, PolysolidJustification::Center => -width * 0.5, diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index 93a696b07..f1c55b1ea 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -343,11 +343,11 @@ pub struct MeshBatchChunk { /// erase — the geometry behind them. pub transp_index_buffer: wgpu::Buffer, pub transp_index_count: u32, - /// Triangle-edge line list for plain meshes that carry no B-rep edges. - /// The dedicated vertex buffer matches the edge pipeline layout. + /// Expanded triangle-edge line list for plain meshes that carry no B-rep + /// edges. Keeping the edge endpoints together avoids coupling this buffer + /// to the surface mesh's indexing and vertex representation. pub wire_vertex_buffer: wgpu::Buffer, - pub wire_index_buffer: wgpu::Buffer, - pub wire_index_count: u32, + pub wire_vertex_count: u32, /// B-rep feature edges of ACIS solids, as a standalone LineList vertex /// buffer (pairs of endpoints), drawn non-indexed. Empty for plain meshes. pub edge_vertex_buffer: wgpu::Buffer, @@ -438,7 +438,7 @@ fn make_chunk( queue.write_buffer(&buffer, 0, bytemuck::cast_slice(data)); buffer }; - let compact_vertices = !material_has_textures(material) && wire_indices.is_empty(); + let compact_vertices = !material_has_textures(material); let mk_plain_vertex = |data: &[MeshVertex]| { if data.is_empty() { return stubs.vertex.clone(); @@ -479,18 +479,22 @@ fn make_chunk( } else { instances }; - let wire_vertices: Vec = if wire_indices.is_empty() { - Vec::new() - } else { - verts - .iter() - .map(|vertex| MeshEdgeVertex { + let mut wire_vertices = Vec::with_capacity(wire_indices.len()); + for line in wire_indices.chunks_exact(2) { + let (Some(start), Some(end)) = ( + verts.get(line[0] as usize), + verts.get(line[1] as usize), + ) else { + continue; + }; + for vertex in [start, end] { + wire_vertices.push(MeshEdgeVertex { position: vertex.position, color: face_color, position_low: vertex.position_low, - }) - .collect() - }; + }); + } + } MeshBatchChunk { vertex_buffer: vertex_buffer_override.unwrap_or_else(|| { if compact_vertices { @@ -505,8 +509,7 @@ fn make_chunk( transp_index_buffer: mk_index(transp_indices, "mesh.batch.transp_ibuf"), transp_index_count: transp_indices.len() as u32, wire_vertex_buffer: mk_edge_vertex(&wire_vertices, "mesh.batch.wire_vbuf"), - wire_index_buffer: mk_index(wire_indices, "mesh.batch.wire_ibuf"), - wire_index_count: wire_indices.len() as u32, + wire_vertex_count: wire_vertices.len() as u32, edge_vertex_buffer: edge_buffer_override .unwrap_or_else(|| mk_edge_vertex(edge_verts, "mesh.batch.edge_vbuf")), edge_vertex_count: edge_verts.len() as u32, @@ -1169,8 +1172,7 @@ fn build_instanced_chunk( let material = first.material; let color = first.color; let source_handle = source.handle.value(); - let compact_vertices = !material_has_textures(material) - && (!first.include_edges || !source.edge_verts.is_empty()); + let compact_vertices = !material_has_textures(material); let material_identity = if material_has_textures(material) { material.map_or(0, |material| { material @@ -1582,7 +1584,10 @@ pub fn build_mesh_batch_filtered( let budget = hard_budget.min(32 * 1024 * 1024); let vsize = std::mem::size_of::(); let max_verts = (budget / vsize).max(3); - let max_tris = (budget / (6 * 4)).max(1); // wire-index buffer: 6 u32 per tri + // A triangle contributes three line segments (six standalone edge + // vertices). Bound chunks by the largest buffer produced for wire meshes. + let max_tris = + (budget / (6 * std::mem::size_of::())).max(1); let stubs = MeshBatchStubs::new(device); let mut chunks = Vec::new(); @@ -1796,18 +1801,27 @@ pub fn build_mesh_batch_filtered( .len() .saturating_mul(std::mem::size_of::()) <= hard_budget - && part.indices.len().saturating_mul(2 * std::mem::size_of::()) + && part.indices.len().saturating_mul(std::mem::size_of::()) <= hard_budget && part .set .instance_source .as_ref() .is_some_and(|source| { - source + let feature_edges_fit = source .edge_verts .len() .saturating_mul(std::mem::size_of::()) - <= hard_budget + <= hard_budget; + let triangle_edges_fit = !part.include_edges + || !source.edge_verts.is_empty() + || part + .indices + .len() + .saturating_mul(2) + .saturating_mul(std::mem::size_of::()) + <= hard_budget; + feature_edges_fit && triangle_edges_fit }) && part .indices @@ -2152,8 +2166,8 @@ pub fn build_mesh_batch_filtered( continue; } - // Flush when adding this mesh would overflow either the vertex buffer - // or the wire-index buffer. + // Flush when adding this mesh would overflow either the surface vertex + // buffer or the expanded wire-vertex buffer. if !verts.is_empty() && (verts.len() + mesh.verts.len() > max_verts || wire_indices.len() / 6 + mesh_tris > max_tris) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 034d057ca..969f9de35 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -3750,7 +3750,7 @@ impl Pipeline { // Four draw paths share this pass: // - Solid: `mesh_pipeline` + triangle index buf. // - Wireframe: `mesh_wireframe_pipeline` + the - // pre-built `wire_index_buffer`. + // pre-built expanded wire vertex buffer. // - HiddenLine: depth prepass (`mesh_depth_pipeline`, // writes Z, no colour) → wire overlay. // - Solid+Edges: `mesh_pipeline` shaded fill → wire @@ -3809,17 +3809,9 @@ impl Pipeline { ); pass.set_vertex_buffer(1, c.instance_buffer.slice(..)); // Plain-mesh triangulation edges. - if c.wire_index_count != 0 { + if c.wire_vertex_count != 0 { pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..)); - pass.set_index_buffer( - c.wire_index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); - pass.draw_indexed( - 0..c.wire_index_count, - 0, - 0..c.instance_count, - ); + pass.draw(0..c.wire_vertex_count, 0..c.instance_count); } // ACIS solid B-rep feature edges (LineList, non-indexed). if c.edge_vertex_count != 0 { @@ -3846,17 +3838,9 @@ impl Pipeline { &[], ); pass.set_vertex_buffer(1, c.instance_buffer.slice(..)); - if c.wire_index_count != 0 { + if c.wire_vertex_count != 0 { pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..)); - pass.set_index_buffer( - c.wire_index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); - pass.draw_indexed( - 0..c.wire_index_count, - 0, - 0..c.instance_count, - ); + pass.draw(0..c.wire_vertex_count, 0..c.instance_count); } if c.edge_vertex_count != 0 { pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..)); @@ -4002,17 +3986,9 @@ impl Pipeline { &[], ); pass.set_vertex_buffer(1, c.instance_buffer.slice(..)); - if c.wire_index_count != 0 { + if c.wire_vertex_count != 0 { pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..)); - pass.set_index_buffer( - c.wire_index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); - pass.draw_indexed( - 0..c.wire_index_count, - 0, - 0..c.instance_count, - ); + pass.draw(0..c.wire_vertex_count, 0..c.instance_count); } if c.edge_vertex_count != 0 { pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..)); diff --git a/src/scene/render_graph.rs b/src/scene/render_graph.rs index 69400ad4f..db73f1f56 100644 --- a/src/scene/render_graph.rs +++ b/src/scene/render_graph.rs @@ -15,6 +15,7 @@ use crate::scene::view::render::{ has_resolved_book_color, is_effective_layer_zero, layer_render_style_viewport, render_style_for_block_sub_viewport, render_style_for_viewport, InheritStyle, }; +use crate::scene::BlockScalePolicy; pub type ResolvedStyle = ([f32; 4], f32, [f32; 8], f32, u8); @@ -216,7 +217,7 @@ fn layer_aci(document: &CadDocument, layer: &str) -> u8 { } #[derive(Clone, Debug)] -pub struct RenderContext { +pub struct InstanceContext { pub transform: Transform, pub root_handle: Handle, pub parent_insert: Handle, @@ -227,9 +228,10 @@ pub struct RenderContext { pub depth_scale: f32, pub nesting_depth: usize, pub viewport: Option, + pub scale_policy: BlockScalePolicy, } -impl RenderContext { +impl InstanceContext { fn direct(depth_base: f32, viewport: Option) -> Self { Self { transform: Transform::identity(), @@ -242,6 +244,7 @@ impl RenderContext { depth_scale: 1.0, nesting_depth: 0, viewport, + scale_policy: BlockScalePolicy::FromInsert, } } @@ -314,8 +317,8 @@ impl<'a> RenderSceneGraph<'a> { /// false for an Insert removes its whole subtree. pub fn walk_root(&self, root: SceneRoot, mut visible: V, mut leaf: F) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { let block = root.content_block(); let viewport = match root { @@ -344,12 +347,18 @@ impl<'a> RenderSceneGraph<'a> { .depths .get(&handle.value()) .map_or(0.0, |depth| depth[0]); - let context = RenderContext::direct(direct_depth, viewport); + let context = InstanceContext::direct(direct_depth, viewport); if !self.document_visible(entity) || !visible(entity, &context) { continue; } if let EntityType::Insert(insert) = entity { - self.walk_insert_instances(insert, &context, &mut visible, &mut leaf); + self.walk_insert_instances( + insert, + BlockScalePolicy::FromInsert, + &context, + &mut visible, + &mut leaf, + ); } else { leaf(entity, &context); self.walk_owned_content(entity, &context, &mut visible, &mut leaf, &mut Vec::new()); @@ -359,15 +368,36 @@ impl<'a> RenderSceneGraph<'a> { /// Walk one synthetic or document-owned Insert. Used by entity renderers /// whose content is itself a block reference. - pub fn walk_insert( + pub fn walk_block_use( &self, - insert: &Insert, + block_use: &BlockUse, root_handle: Handle, mut visible: V, mut leaf: F, + ) + where + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), + { + self.walk_insert_with_policy( + &block_use.insert, + root_handle, + block_use.scale_policy, + &mut visible, + &mut leaf, + ); + } + + fn walk_insert_with_policy( + &self, + insert: &Insert, + root_handle: Handle, + scale_policy: BlockScalePolicy, + visible: &mut V, + leaf: &mut F, ) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { let mut root_insert = insert.clone(); root_insert.common.handle = root_handle; @@ -376,9 +406,15 @@ impl<'a> RenderSceneGraph<'a> { .depths .get(&root_handle.value()) .map_or(0.0, |depth| depth[0]); - let context = RenderContext::direct(depth_base, self.viewport); + let context = InstanceContext::direct(depth_base, self.viewport); if self.document_visible(&entity) && visible(&entity, &context) { - self.walk_insert_instances(&root_insert, &context, &mut visible, &mut leaf); + self.walk_insert_instances( + &root_insert, + scale_policy, + &context, + visible, + leaf, + ); } } @@ -386,12 +422,13 @@ impl<'a> RenderSceneGraph<'a> { fn walk_insert_instances( &self, insert: &Insert, - parent: &RenderContext, + scale_policy: BlockScalePolicy, + parent: &InstanceContext, visible: &mut V, leaf: &mut F, ) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { let insert_entity = EntityType::Insert(insert.clone()); let block_style = parent @@ -424,6 +461,7 @@ impl<'a> RenderSceneGraph<'a> { insert, offset, self.annotation_scale, + scale_policy, ); let transform = local.then(&parent.transform); let mut context = parent.clone(); @@ -435,6 +473,7 @@ impl<'a> RenderSceneGraph<'a> { context.depth_base = depth_base; context.depth_scale = depth_scale; context.nesting_depth += 1; + context.scale_policy = scale_policy; if let Some(filter) = crate::scene::pick::xclip::insert_spatial_filter( self.document, insert, @@ -465,13 +504,13 @@ impl<'a> RenderSceneGraph<'a> { fn walk_block( &self, block_name: &str, - context: &RenderContext, + context: &InstanceContext, visible: &mut V, leaf: &mut F, stack: &mut Vec, ) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { if context.nesting_depth > 32 { return; @@ -504,7 +543,13 @@ impl<'a> RenderSceneGraph<'a> { continue; } stack.push(nested.block_name.clone()); - self.walk_insert_instances(nested, context, visible, leaf); + self.walk_insert_instances( + nested, + BlockScalePolicy::FromInsert, + context, + visible, + leaf, + ); stack.pop(); } _ => { @@ -518,13 +563,13 @@ impl<'a> RenderSceneGraph<'a> { fn walk_owned_content( &self, entity: &EntityType, - context: &RenderContext, + context: &InstanceContext, visible: &mut V, leaf: &mut F, stack: &mut Vec, ) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { for block_use in entity_render_block_uses(self.document, entity, self.annotation_scale) .into_iter() @@ -537,7 +582,13 @@ impl<'a> RenderSceneGraph<'a> { continue; } stack.push(block_use.insert.block_name.clone()); - self.walk_insert_instances(&block_use.insert, context, visible, leaf); + self.walk_insert_instances( + &block_use.insert, + block_use.scale_policy, + context, + visible, + leaf, + ); stack.pop(); } } @@ -595,9 +646,24 @@ pub fn insert_transform_at_scale( document: &CadDocument, insert: &Insert, annotation_scale: f32, +) -> Transform { + insert_transform_with_policy( + document, + insert, + annotation_scale, + BlockScalePolicy::FromInsert, + ) +} + +pub fn insert_transform_with_policy( + document: &CadDocument, + insert: &Insert, + annotation_scale: f32, + scale_policy: BlockScalePolicy, ) -> Transform { let mut transform = insert_transform(document, insert); - if (annotation_scale - 1.0).abs() > 1.0e-6 + if scale_policy == BlockScalePolicy::FromInsert + && (annotation_scale - 1.0).abs() > 1.0e-6 && insert .common .extended_data @@ -615,7 +681,7 @@ pub fn insert_transform_at_scale( transform } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum BlockRole { Insert, DimensionPicture, @@ -631,6 +697,7 @@ pub struct BlockUse { pub block: Handle, pub insert: Insert, pub role: BlockRole, + pub scale_policy: BlockScalePolicy, /// Whether this edge belongs to the active render representation. Inactive /// edges remain dependencies so cache invalidation and purge stay complete. pub active: bool, @@ -654,6 +721,7 @@ fn block_use( document: &CadDocument, mut insert: Insert, role: BlockRole, + scale_policy: BlockScalePolicy, active: bool, replaces_host_wire: bool, suppress_root_points: bool, @@ -667,6 +735,7 @@ fn block_use( block, insert, role, + scale_policy, active, replaces_host_wire, suppress_root_points, @@ -687,6 +756,7 @@ pub fn block_use_from_handle( document, Insert::new(record.name.clone(), insertion), role, + BlockScalePolicy::Applied, true, false, false, @@ -727,6 +797,7 @@ fn entity_owned_block_uses( document, insert.clone(), BlockRole::Insert, + BlockScalePolicy::FromInsert, true, true, false, @@ -743,6 +814,7 @@ fn entity_owned_block_uses( document, insert, BlockRole::DimensionPicture, + BlockScalePolicy::FromInsert, picture, picture, true, @@ -771,6 +843,7 @@ fn entity_owned_block_uses( document, insert, BlockRole::TablePicture, + BlockScalePolicy::FromInsert, true, true, false, @@ -780,15 +853,12 @@ fn entity_owned_block_uses( uses.extend( crate::entities::table::block_cell_inserts(table, document, annotation_scale) .into_iter() - .map(|mut insert| { - insert - .common - .extended_data - .remove_record("AcAnnotativeData"); + .map(|insert| { block_use( document, insert, BlockRole::TableCell, + BlockScalePolicy::Applied, cells_active, false, false, @@ -799,15 +869,12 @@ fn entity_owned_block_uses( } EntityType::MultiLeader(multileader) => { crate::entities::multileader::block_content_insert(document, multileader) - .map(|mut insert| { - insert - .common - .extended_data - .remove_record("AcAnnotativeData"); + .map(|insert| { vec![block_use( document, insert, BlockRole::MultiLeaderContent, + BlockScalePolicy::Applied, true, false, false, @@ -997,8 +1064,9 @@ pub fn insert_instance_transform( insert: &Insert, offset: [f64; 3], annotation_scale: f32, + scale_policy: BlockScalePolicy, ) -> Transform { - let transform = insert_transform_at_scale(document, insert, annotation_scale); + let transform = insert_transform_with_policy(document, insert, annotation_scale, scale_policy); if offset == [0.0; 3] { transform } else { @@ -1007,6 +1075,32 @@ pub fn insert_instance_transform( } } +pub fn insert_instance_translation_delta( + document: &CadDocument, + insert: &Insert, + offset: [f64; 3], + annotation_scale: f32, + scale_policy: BlockScalePolicy, +) -> Vector3 { + let base = insert_instance_transform( + document, + insert, + [0.0; 3], + annotation_scale, + scale_policy, + ) + .apply(Vector3::ZERO); + let instance = insert_instance_transform( + document, + insert, + offset, + annotation_scale, + scale_policy, + ) + .apply(Vector3::ZERO); + instance - base +} + pub fn block_contains_hatch( document: &CadDocument, block_name: &str, @@ -1035,3 +1129,38 @@ pub fn block_contains_hatch( memo.insert(key, contains); contains } + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::xdata::ExtendedDataRecord; + + #[test] + fn applied_block_scale_skips_annotative_rescaling() { + let document = CadDocument::new(); + let mut insert = Insert::new("BLOCK", Vector3::new(10.0, 20.0, 0.0)); + insert + .common + .extended_data + .add_record(ExtendedDataRecord::new("AcAnnotativeData")); + let point = Vector3::new(1.0, 0.0, 0.0); + + let from_insert = insert_transform_with_policy( + &document, + &insert, + 2.0, + BlockScalePolicy::FromInsert, + ) + .apply(point); + let applied = insert_transform_with_policy( + &document, + &insert, + 2.0, + BlockScalePolicy::Applied, + ) + .apply(point); + + assert!((from_insert.x - 12.0).abs() < 1.0e-9); + assert!((applied.x - 11.0).abs() < 1.0e-9); + } +} diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 5bb37dd15..6f38ec873 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -531,6 +531,17 @@ pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, .into() } +/// Render an icon with the foreground chosen for a success-coloured surface. +pub fn themed_success_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(themed_handle(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.palette().success.base.text), + }) + .into() +} + /// Render a warning-state chrome icon from the active Iced theme. pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { svg(themed_handle(bytes)) diff --git a/src/ui/window/shortcuts.rs b/src/ui/window/shortcuts.rs index 13e2735de..63440fa33 100644 --- a/src/ui/window/shortcuts.rs +++ b/src/ui/window/shortcuts.rs @@ -19,13 +19,54 @@ fn muted_style(theme: &Theme) -> iced::widget::text::Style { } } -pub fn view_window( - rows: &[(String, String)], +fn danger_text_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + } +} + +/// Text-input variant flagging an invalid value: danger border and value +/// color on the theme's danger weak background. +fn danger_input_style( + theme: &Theme, + status: iced::widget::text_input::Status, +) -> iced::widget::text_input::Style { + let danger = theme.palette().danger.base; + iced::widget::text_input::Style { + background: Background::Color(theme.palette().danger.weak.color), + border: iced::border::rounded(4) + .color(danger.color) + .width( + if matches!( + status, + iced::widget::text_input::Status::Focused { is_hovered: _ } + ) { + 1.5 + } else { + 1.0 + }, + ), + icon: danger.color, + placeholder: danger.color.scale_alpha(0.7), + value: danger.text, + selection: danger.color, + } +} + +pub fn view_window<'a>( + rows: &'a [(String, String)], + capture_row: Option, + pending_add: bool, + reset_confirm: bool, + duplicate_keys: &rustc_hash::FxHashSet, + duplicate_conflicts: &[(String, String)], + unknown_commands: &rustc_hash::FxHashSet, + close_confirm: bool, sizing: crate::ui::modal::ModalSizing, -) -> Element<'_, Message> { +) -> Element<'a, Message> { let title = text(t!("Keyboard Shortcuts")).size(15); let hint = text(t!( - "Type SHORTCUTS SET to add custom shortcuts." + "Click + Add, press a key combination, type the command, then Apply. Esc cancels the pending row." )) .size(11) .style(muted_style); @@ -49,15 +90,42 @@ pub fn view_window( let mut list = column![].spacing(3); for (idx, (key, command)) in rows.iter().enumerate() { - let key_box = text_input("Ctrl+Key", key) - .on_input(move |value| Message::ShortcutEditorInput { - idx, - field: ShortcutField::Key, - value, - }) - .size(13) - .padding([3, 6]) - .width(Length::Fixed(180.0)); + // Key cell: click to arm capture, then press the combination. The + // armed cell shows what is expected and highlights until a key + // arrives; clicking it again cancels. A key that already exists in + // another row is shown red. Manual key entry is available via + // SHORTCUTS SET. + let armed = capture_row == Some(idx); + let duplicate = !key.is_empty() && duplicate_keys.contains(key.as_str()); + let key_label = if armed { + t!("Press a key combination...").into_owned() + } else if key.is_empty() { + t!("Click, then press keys...").into_owned() + } else { + key.clone() + }; + let key_box = if armed { + button(text(key_label).size(13)) + .on_press(Message::ShortcutCaptureClear) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::primary) + } else if duplicate { + // The whole field turns red, not just the text. + button(text(key_label).size(13)) + .on_press(Message::ShortcutCaptureStart(idx)) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::danger) + } else { + button(text(key_label).size(13).style(muted_style)) + .on_press(Message::ShortcutCaptureStart(idx)) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::secondary) + }; + let unknown_command = + !command.is_empty() && unknown_commands.contains(command.trim()); let command_box = text_input(t!("command").as_ref(), command) .on_input(move |value| Message::ShortcutEditorInput { idx, @@ -67,13 +135,50 @@ pub fn view_window( .size(13) .padding([3, 6]) .width(sizing.width); - let remove = button(crate::ui::icons::themed_danger_text( - crate::ui::icons::CLOSE, - 12.0, - )) - .on_press(Message::ShortcutEditorRemove(idx)) - .padding([2, 6]) - .style(button::danger); + // An unrunnable command turns the whole field red, mirroring the + // duplicate key cell. + let command_box = if unknown_command { + command_box.style(danger_input_style) + } else { + command_box + }; + // Draft rows get a check (finish the addition, without applying) and + // a cancel ✕; committed rows get the trash bin. + let remove = if pending_add && idx == 0 { + // Done is only clickable for a valid row: key and command filled, + // key not already used, command actually runnable. + let done_ok = !key.is_empty() + && !command.is_empty() + && !duplicate + && !unknown_commands.contains(command.trim()); + row![ + button(crate::ui::icons::themed_success_text( + crate::ui::icons::CHECK, + 12.0, + )) + .on_press_maybe(done_ok.then_some(Message::ShortcutEditorDraftAccept)) + .padding([2, 6]) + .style(button::success), + button(crate::ui::icons::themed_danger_text( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::ShortcutCaptureCancel) + .padding([2, 6]) + .style(button::danger), + ] + .spacing(4) + .align_y(iced::Center) + } else { + row![button(crate::ui::icons::themed_danger_text( + crate::ui::icons::TRASH, + 12.0, + )) + .on_press(Message::ShortcutEditorRemove(idx)) + .padding([2, 6]) + .style(button::danger)] + .align_y(iced::Center) + }; list = list.push( row![key_box, command_box, remove] .spacing(8) @@ -81,16 +186,100 @@ pub fn view_window( ); } - let add = button(text(format!("+ {}", t!("Add"))).size(12)) - .on_press(Message::ShortcutEditorAdd) - .padding([4, 10]) - .style(button::secondary); + // While a draft row is pending, the add button doubles as the visible + // cancel affordance (Esc works too). + let add = if pending_add { + button(text(t!("Cancel add (Esc)")).size(12)) + .on_press(Message::ShortcutCaptureCancel) + .padding([4, 10]) + .style(button::danger) + } else { + button(text(format!("+ {}", t!("Add"))).size(12)) + .on_press(Message::ShortcutEditorAdd) + .padding([4, 10]) + .style(button::secondary) + }; + // The reset confirmation replaces the shortcut count so the warning + // gets the whole left side of the bar. + let stats = if reset_confirm { + row![Space::new().width(Length::Fixed(0.0))] + } else { + row![text(format!("{}: {}", t!("Number of shortcuts"), rows.len())) + .size(12) + .style(muted_style)] + }; + // Reset asks for confirmation in place: the add/reset buttons are + // replaced by the question with Yes / No. + let (reset_area, add_area) = if reset_confirm { + ( + row![ + text(t!( + "Are you sure you want to reset? You will lose all your current shortcuts!" + )) + .size(12) + .style(danger_text_style), + button(text(t!("Yes, reset")).size(12)) + .on_press(Message::ShortcutEditorResetConfirm) + .padding([4, 10]) + .style(button::danger), + button(text(t!("No")).size(12)) + .on_press(Message::ShortcutEditorResetDeny) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(8) + .align_y(iced::Center), + row![Space::new().width(Length::Fixed(0.0))], + ) + } else { + ( + row![button(text(t!("Reset to default")).size(12)) + .on_press(Message::ShortcutEditorResetAsk) + .padding([4, 10]) + .style(button::secondary)] + .spacing(8) + .align_y(iced::Center), + row![add], + ) + }; let apply = button(text(t!("Apply")).size(12)) .on_press(Message::ShortcutEditorApply) .padding([4, 16]) .style(button::primary); + let apply_exit = button(text(t!("Apply && Exit")).size(12)) + .on_press(Message::ShortcutEditorApplyExit) + .padding([4, 16]) + .style(button::primary); + // The reset confirmation takes over the whole action bar. + let apply_area = if reset_confirm { + row![Space::new().width(Length::Fixed(0.0))] + } else { + row![apply, apply_exit].spacing(8).align_y(iced::Center) + }; - container( + // Persistent duplicate warning: shown until every conflicting key is + // resolved (re-captured, changed, or a row removed). + let mut conflict_banner = column![].spacing(2); + for (key, command) in duplicate_conflicts { + conflict_banner = conflict_banner.push( + text(crate::tf!( + "Shortcut already used for command: {} → {}", + key, + command + )) + .size(12) + .style(danger_text_style), + ); + } + for command in unknown_commands { + conflict_banner = conflict_banner.push( + text(crate::tf!("Unknown command: {}", command)) + .size(12) + .style(danger_text_style), + ); + } + + let content = container( column![ title, hint, @@ -98,7 +287,11 @@ pub fn view_window( head, scrollable(container(list).padding(gutter)).height(sizing.height), Space::new().height(6), - row![add, Space::new().width(sizing.width), apply].align_y(iced::Center), + conflict_banner, + Space::new().height(4), + row![stats, add_area, reset_area, Space::new().width(sizing.width), apply_area] + .spacing(8) + .align_y(iced::Center), ] .spacing(6) .width(sizing.width) @@ -112,6 +305,66 @@ pub fn view_window( theme.palette().background.base.color, )), ..Default::default() - }) - .into() + }); + + // Unsaved-changes guard: closing with un-applied rows stacks a dimmed + // shield and a confirmation panel on top of the editor. + if !close_confirm { + return content.into(); + } + let shield = iced::widget::mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme + .palette() + .background + .strongest + .color + .scale_alpha(0.55), + )), + ..Default::default() + }), + ) + .on_press(Message::ShortcutEditorCloseKeep) + .interaction(iced::mouse::Interaction::Idle); + let panel = container( + column![ + text(t!("Unsaved changes will be discarded.")).size(14), + Space::new().height(10), + row![ + button(text(t!("Discard && close")).size(12)) + .on_press(Message::ShortcutEditorCloseDiscard) + .padding([4, 12]) + .style(button::danger), + button(text(t!("Keep editing")).size(12)) + .on_press(Message::ShortcutEditorCloseKeep) + .padding([4, 12]) + .style(button::secondary), + ] + .spacing(8), + ] + .spacing(4), + ) + .padding(16) + .width(Length::Fixed(320.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + border: iced::Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }); + let centered = container(panel) + .width(Length::Fill) + .height(Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center); + iced::widget::stack![content, iced::widget::opaque(shield), centered].into() } diff --git a/tests/text_font_rendering.rs b/tests/text_font_rendering.rs index eb741373c..0209a3b82 100644 --- a/tests/text_font_rendering.rs +++ b/tests/text_font_rendering.rs @@ -59,6 +59,7 @@ fn expand_block_mtext( &Default::default(), ); expand_insert( + &doc, &cache, &ins, Handle::new(999), @@ -86,6 +87,8 @@ fn expand_block_mtext( // Annotation scale: 1.0 = no annotative scaling, matching the // `BlockCache::build(&doc, 1.0, ...)` call above. 1.0, + OpenCADStudio::scene::BlockScalePolicy::FromInsert, + false, ) .expect("block defn is cached") }